2026-08-04 uv Project Registry and the devTools Gate
What I set out to do
Another discussion that turned into work. The opening question was whether the
Python projects, which build out of a separate tree from nix/pkgs/, should
move under pkgs/ and each get an enable flag “similar to how we’ve set the
dendritic enablement.” Three separable ideas in one sentence, and they did not
all survive contact with the tree. Five commits landed on
refactor/uv-project-registry, then three more on main after the merge, as
the session carried on into the last unpackaged scripts.
What I actually did
Splitting the proposal
The move-under-pkgs/ half does not hold up. pkgs/ holds seven build
recipes for third-party source pinned by flake = false inputs, 600-2000
bytes each. The uv projects are first-party workspaces, 45-56 tracked files
apiece with their own justfile, CHANGELOG, and dev-shell flake. And
files/scripts/ is one deploy tree, not a Python tree: shell.nix string-appends
into it for ~/bin, lib/scripts.nix, tmux.nix, sqlite-seed.nix, and
signoz.nix all read from it. Moving only the Python subdirectories splits it
for no gain, since the derivations never lived there either way.
What was missing is a registry. Five projects, each built at its consumer
site, and tests/uv-projects.nix carrying a hardcoded list of four names plus
its own second copy of the pythonSet assembly from lib/uv.nix. Nothing
related the two.
The per-package enable flag half is the one the dendritic reading kills, and
2026-07-20 lib.uv Refactor and athena 26.05 Pin had already recorded why:
dendritic lists boolean enable options as an anti-pattern. That note framed
it as the tax auto-discovery charges. Today it landed as a direct contradiction
of the proposal that opened the session — the answer was to use the flag that
already exists, not to mint five more.
Registry (commit b55df95)
nix/lib/uv-projects.nix names the five projects once (claude-ops,
claude-skill-sync, gh-review-preview, permission-suggestion,
obsidian-mcp-proxy). The flake maps it into perSystem.packages, so
easyOverlay carries every one to pkgs.<name> and nix build .#claude-ops
works — the same reachability the ./pkgs entries already had. checks
receives the built packages, not a second list of names.
The registry throws in both directions: a directory with a uv.lock that is
not registered, and a registered project whose directory is gone. Tested both
branches with a throwaway probe directory before wiring it in.
lib/uv.nix now exposes testVenv (deps.all) and src through passthru,
which is what let tests/uv-projects.nix drop its duplicate pythonSet. The
duplication was not cosmetic: a build-system override or a sourcePreference
change had to be made in two files to take effect, so a suite could pass
against an environment the shipped package never used.
Consequence I did not expect going in: lib.uv is gone from the module-arg
lib. Once every call site resolved through pkgs.<name>, the extension
point had zero consumers. That extension was the centerpiece of 7b40430 six
weeks ago.
The devTools gate (commit 15395f6)
claude-ops and gh-review-preview deployed on every host importing
shell.nix, athena included, so a headless Intel server built duckdb and
bashlex for a session-analysis tool with no sessions and a PR-review helper
with no reviews.
This is not a missing guard, and the comments on record said so: both are
self-contained venvs needing no uv CLI, so local.python.enable correctly did
not apply. That reasoning is right and incomplete. It answers whether a host
can run them, never whether it wants them. local.devTools.enable is the
flag that encodes the latter and profile = "server" already clears it.
Wrote the contract entry in tests/capability-flags.nix first; it failed on
all three surfaces (both packages, plus the completion activation) before the
guard existed, and the suite’s own vacuity check confirmed the entry was not
asserting nothing. machine.nix and home-server.nix each claimed
local.python.enable covered these tools. Neither was true before this change
either; both corrected.
Housekeeping (commit 36fc283, and a deletion)
.gitignore had a bare result, which misses the result-1, result-2, …
that a multi-installable nix build writes — they staged themselves into my
first commit attempt.
Deleted files/scripts/bash-command-validator: 690 files and 33 MB of .venv
plus caches with zero source files, left behind when the project’s source
was removed. Checked for real source before removing rather than trusting the
directory name.
A lint that disagreed with itself (commit 2ef6930)
Followed up on what I had written off above as a pre-existing ruff version skew. It was not a skew, and the real cause is the best finding of the day.
The root flake’s pre-commit-run rewrote test_ipython_config.py’s import
block on every run, while just check in the worktree called the same file
clean. Same ruff (0.16.1), same config, opposite answers. Every hook reported
Passed and a diff was left behind anyway, which is what made it look like
version noise.
Ruff’s isort infers first-party by resolving a module name against src,
which defaults to the project root — and this repo’s root is ~/.config
itself, which carries a gitignored ipython/ directory: the real IPython
user config that home-manager deploys. macOS APFS is case-insensitive, so
IPython matches ipython/ and resolves first-party in any working tree.
A flake’s source is only its git-tracked files, so that directory does not
exist inside the build sandbox and the same import resolves third-party
there. The file could not satisfy both gates; it lost to whichever ran last.
Fixed by declaring the classification instead of inferring it — a ruff.toml
scoped to that test directory with known-third-party = ["IPython"] — and
moving the import to the block that implies. Verified under both conditions:
the worktree, and a copy containing only git-tracked files. Scoped rather than
root-level because the repo has no root ruff config, so adding one would newly
govern every loose .py file in the tree.
Correcting my own account of obsidian-mcp-proxy (commit 338d009)
I had written into the registry header that a project “could be built by a
module and tested by nothing, which is what had happened to
obsidian-mcp-proxy.” Wrong, and the correction I offered when challenged
(that the omission was deliberate) was also wrong. The dates settle it:
tests/uv-projects.nix was created 2026-07-01, obsidian-mcp-proxy on
2026-08-03. The list predates the project by a month, and the same commit that
created the project added tests/obsidian-mcp-bridge, which covers it. It
ships no tests/ of its own because it is a lockfile pin over upstream
mcp-proxy with no first-party code.
So nothing had drifted. I had named a failure that never happened to justify a change that stands perfectly well on its own.
The last three PEP 723 scripts (commits 052b576, d4256a1)
With the registry in place, taking inventory of files/scripts/ left four
loose files. claude-with-timeout went first: reading it showed an abandoned
prototype whose own body says # this is conceptual - actual detection TBD,
with a FIFO written by a monitor subshell and read by nobody. Deleted.
The other three — arxiv-mcp-server, acl-mcp-server,
check-upstream-issues — were PEP 723 scripts run as uv run --script, which
re-resolves the dependency tree from PyPI on every invocation. For the two
MCP servers that means the server needs network to boot, its version set
drifts silently underneath it, and nothing can be built or exercised in a
sandbox. Now registered uv projects with committed uv.lock files; mcp.nix
launches them from the store instead of via uv run.
I started hand-rolling pyproject.toml files and was redirected mid-task to
the copier template. The convention was discoverable the whole time — every
existing project carries a .copier-answers.yml naming
gh:achhina/python-project-template and its answers. Regenerated all three
from v1.8.0 with matching answers, moved sources in with git mv so rename
detection held at ~95%. All three picked up pytest checks automatically,
because the template generates tests/ and the registry’s check derivation
keys off pathExists src/tests.
Two things I got wrong here. I reported acl-mcp-server as having an
unresolvable dependency conflict; it was self-inflicted — I had set
requires-python = ">=3.11", and uv lock resolves universally across
every Python in the declared range, so one unsatisfiable interpreter fails the
whole lock. The template’s >=3.13 fixed it, and I verified the original dep
set resolves before concluding. Separately I added lxml-stubs to fix one ty
error and it produced 53 more, because the stubs type xpath() as a wide
union needing casts at ~50 sites. Backed out; lxml stays untyped behind one
scoped ty: ignore with the reason written down.
Paying the lint debt instead of ignoring it (commit a4a6684)
Packaging held those scripts to the template’s rule set for the first time,
and the packaging commit bought that with per-file ignores — which is the
thing worth flagging, because the ignore list did not distinguish a constraint
from a defect. RUF029/PLW0603/PLC0415 describe how an MCP server is
shaped: coroutine tool handlers that do no awaiting, a module-global rate-limit
clock, a deferred heavyweight import. A002, B904, E501, N818,
PLR0911, PLR0912, PLR0917 and PLW0717 were simply unpaid, sitting in
the same list under the same comment.
Fixed rather than suppressed. In arxiv: the two private S2 exceptions gained
the Error suffix; the exhausted-retry path chains with raise ... from err
so the tenacity failure survives into the traceback; daily_papers’ format
became feed_format; and harvest_metadata/s2_search take their optional
filters keyword-only, which costs nothing because MCP and click both already
pass by name. In check-upstream-issues: format_time_ago dropped from a
13-statement try block to one narrow try around fromisoformat plus a shared
pluralizer, and main’s per-issue loop moved out to collect_statuses.
Wrote a characterization suite for the two refactored functions first. The 16
format_time_ago cases passed against the old implementation before the
rewrite, which is what makes them a safety net rather than a description of
whatever I happened to write.
Then cleared the per-file-ignore table entirely and re-ran, to confirm every remaining entry still fires. All six do. Each now carries its own reason instead of sharing one paragraph with the debt.
The lxml suppression I had talked myself out of (commit bb191d8)
Asked whether the lxml thing was fixed too. It was not — it was the one inline suppression left, and the comment I had written above it was doing the arguing: “needs a cast at roughly fifty call sites.”
That estimate was wrong in shape, not just in count. Re-added the stubs and
looked at where the 48 diagnostics actually land: one contiguous parsing block,
routing through exactly three xpath result shapes. A scalar string(), a
text()/@attr list, an element list. Three narrowing wrappers — _xs,
_xstrs, _xels — retire all 48. _xs already existed; I had written it and
then not noticed it was the first of a set.
lxml-stubs went into the type-check dependency group, so it stays out of the
runtime closure (checked against the built store path, not just the lockfile).
Two call sites collapsed into _xs on the way: the resumption token and the
Atom link href were each open-coding str(xpath("string(...)")).
Since this rewrote the parsing core and the parsing core had zero coverage,
it now has a fixture-XML suite: both _parse_rss branches, an OAI record with
no metadata block, the api/errors entry, and the resumption token — that last
one because its whitespace stripping drives pagination and nothing else would
have caught losing it.
The “structural” MCP ignores, two of which were not (commits dda09a3, 25f547e)
Asked why the MCP servers keep RUF029/PLW0603/PLC0415. Went to check
rather than restate what I had written. Only one survived.
PLC0415 holds. import pymupdf4llm inside the one PDF-conversion
function in each server. Heavyweight, one code path, real trade-off.
RUF029 does not. I had written that the handlers “cannot become sync
without changing how they are registered.” fastmcp registers sync tools fine,
and FunctionTool defaults run_in_thread=True for them. So the waiver was
not merely unearned — it was covering a defect. Declared async with no
await, those functions ran their blocking work on the event loop: acl’s
six all call _load_anthology(), arxiv’s two do disk reads. Declared sync,
fastmcp hands each to a worker thread. The only thing coupling them to async
was _run calling asyncio.run unconditionally; one isawaitable branch.
PLW0603 is softer than I claimed. The state is genuinely shared, but
that argues for naming it, not for rebinding a module global from inside a
coroutine. Both servers now hold it in a _RateLimitState dataclass, and the
Anthology memo became functools.cache — which was all the global ever did.
And the audit turned up a bug. My comment said the corpus load was
“deferred to first use rather than import time.” It was not: cli.py:347
called _load_anthology() at module scope, so importing the module loaded the
corpus and every CLI subcommand paid. --help took 8.7s; it is 1.3s now, with
the load moved into serve where warm-up actually belongs.
Then a fourth: my “zero inline suppressions” claim from the previous turn was
wrong. acl carried # ruff: file-ignore[blind-except, try-except-pass] on line
4, and my sweep regex matched ruff: ignore — not ruff: file-ignore. The
justification was real (acl_anthology’s optional fields vary by record type)
but file-scoped, so it also silently covered a blind except 280 lines away in
acl_author_papers. Those reads funnel through one _best_effort guard now,
which is also where the suppression lives.
Ruff’s unsafe autofix then demonstrated the exact failure mode mid-refactor:
it rewrote lambda: ns.name.as_first_last() to a bare
ns.name.as_first_last method reference, which moves the ns.name lookup
outside the guard — and an author record missing name is precisely what
the fallback exists for. Caught it reading the diff. There is now a test that
fails if anyone “simplifies” it again.
The anthology data dir (commit b6d63e5)
The author/papers failure turned out to be more interesting than “stale
clone.” My first read — that from_repo “would build the indices” — was wrong:
from_repo is clone_or_pull_from_repo followed by the same
Anthology(datadir=...) call the disk branch makes. The index build is
identical either way.
The real difference is the pull. ~/.acl-anthology-data was a February
checkout, 5108 commits behind, and upstream had since replaced data/yaml
with data/json — where the person, venue and SIG indices each read a file.
_load_anthology gated the disk branch on data/xml alone, which a checkout
missing json/ satisfies, so it loaded happily and the three indices failed
later, lazily, at first use. Hence the split symptom: paper, bibtex, read
and list fine, author and papers raising.
So the disk branch was not just permissive, it was a trap: once taken it
never updates anything, so a checkout that goes stale past an upstream layout
change stays broken forever. The fix names every directory the reader opens
and falls through to from_repo otherwise, which pulls.
Nearly hardcoded ("xml", "yaml", "json") from what I saw before the pull.
Checked after: yaml/ is gone. That would have made every load look
incomplete and re-pulled the 709MB repo on every invocation — the same bug
mirrored. There is now a test pinning the required set for exactly that reason.
Reopening the question I closed at the start (commit d44c48e)
Asked why the projects are still in files/scripts. Went back to the tree
instead of re-reading my own argument, and the argument had stopped holding.
files/scripts/ had become two unrelated things sharing a name: ~20 loose
scripts plus tmux/, read by path from eight different modules, and eight
uv projects read by path from two lines — the registry root and the
obsidian-mcp-bridge test. My rejection was “it is one deploy tree, splitting it
gains nothing.” True of the scripts, never true of the projects: they reach
modules as pkgs.<name> through the overlay and do not participate in the
deploy tree at all. They also sat under home-manager/files/, which means
“file sources for home.file” — something they never touch.
Moved to nix/projects/, sibling of pkgs/ and lib/. Kept declining the
original pkgs/ proposal for the same reason as this morning: that is
third-party build recipes over flake = false inputs, 600-2000 bytes each.
Five of the eight derivations are byte-identical across the move, which is what shows the move is inert; the three that changed are the ones I edited alongside it.
Three things the move surfaced, all latent beforehand:
shell.nixstill said the~/binpython scripts “declare their deps in PEP-723 headers.” False since this morning —binScriptsis two bash scripts._run’s in-place narrowing typed differently under two ty environments. The project’s ownuv run typassed; the pre-commit hook’s ty joined the branches toobject. Same pinned version, 0.0.65 both. Annotating the resultAnymakes it independent of how a given typeshed declaresisawaitableandasyncio.run.- gitleaks flagged claude-skill-sync’s PBKDF2 test vectors. Not new: they had
simply never been inside a staged diff since the allowlist was written, and
prek runonly sees staged files. Allowed inline rather than by path, so the next move cannot break it again.
The last two, which I had twice described as bash (commits 254126b, 0c95ea2)
Asked whether everything should move under projects/ and scripts/ be
deleted. No — and the inventory that answers it is the interesting part. Of 21
tracked entries in files/scripts/, two are actual home.file sources.
The other 19 are read by path into derivations or spliced into Nix strings.
So home-manager/files/, a directory whose name means “things home.file
deploys,” was 2/21 that. AGENTS.md still said “Deploy via home.file in
shell.nix” as the house rule.
And the two that were file sources are parse-history and
parse-claude-tools — which this entry, higher up, records me calling “plain
bash” in a shell.nix comment I had written that same day while removing the
previous stale comment from the same lines. They are
#!/usr/bin/env -S uv run --script with PEP 723 headers. I then repeated the
claim in this journal’s own “Still open” list. Twice wrong about the same two
files, in the same day, in both the code and the record of the code.
Packaged them properly. Three defects fell out of the port:
parse-claude-toolsdeclaredclaude-code-logand never imported it. Dropped rather than locked — locking would have made an unused closure permanent. Under PEP 723 it was re-resolved from PyPI on every launch, so the tool was one release of an unused dependency away from failing to start.parse-historycaughtbashlex.errors.ParsingErrorwhile importing onlybashlex. Works solely because the package’s__init__happens to import the submodule. ty flagged it; nothing else would have.- The
main()signature took a parameter namedjson, shadowing the module it calls two frames down.
55 unit tests pin the ported behaviour. One of them, test_first_non_flag_ argument_becomes_the_subcommand, failed on first run — and my test was the
thing describing desired behaviour rather than actual. git -c k=v commit
reports git k=v, because “first argument not starting with -” cannot tell a
subcommand from a separated flag’s value. Carried over unchanged and pinned
under a name that says it is a limitation, so a future fix is deliberate rather
than accidental.
Both now gate on local.devTools.enable, which is a real removal for athena:
it carried them unconditionally via home.file and will not any more. Defensible
because parse-claude-tools reads the same ~/.claude/projects transcripts as
claude-ops, already gated that way — so it extends a decision rather than
making a new one. Flagged it rather than burying it, since it is a behaviour
change nobody asked for.
Also extended the capability-flags contract to cover check-upstream-issues,
which had been devTools-gated in shell.nix since this morning with no contract
entry asserting it.
The ignore that hid two whole projects (commit 0c95ea2)
After committing, the repo-level hook run listed six project workspaces.
The directory holds eight. prek list agreed: six.
.gitignore carried a bare, unanchored .pre-commit-config.yaml, there to
ignore the root config that git-hooks.nix generates. Unanchored, it also
matched every per-project config under nix/projects/. prek discovers a nested
project only when its config is tracked, so both new projects were invisible to
the repo-level gate — just check returned 0 by not looking at them.
The six that predate this were each git add -f’d past the pattern, one at a
time, which is why nobody noticed the pattern was wrong. Anchored it to
/.pre-commit-config.yaml; root stays ignored, nested ones are tracked
normally. Tracking them immediately put them in yamlfmt’s scope and it had 24
lines of complaints, which is the point: an ignored file is not merely
untracked, it is unchecked.
This is the second time this repo’s .gitignore has swallowed files through an
unanchored pattern, and the previous one is already written down.
Renaming the directory the name had stopped describing (commit b3ad2cd)
home-manager/files/scripts/ → nix/scripts/. The name meant “sources
home.file deploys”; after d44c48e took the uv projects out and 254126b
packaged the last two raw file sources, it described zero of the 19
remaining entries. All of them are read by path into derivations or spliced
into Nix strings, and the nine ~/bin entries are symlinks to derivations. So
it is a third kind of source tree, and it sits beside pkgs/ and projects/
now. What stays under home-manager/files/ is the eight entries the name is
true of — tridactylrc, dashboards, the atuin seed history.
The rename is provably inert, and the proof was worth doing. athena’s activation derivation is byte-identical. atlas’s moved, which should not happen for a pure rename — so I diffed the two closures rather than assuming. The entire difference is one string: the SigNoz alert annotation that names the file to edit when the raw-body filename regex breaks. It is a doc pointer inside an alert body, so renaming the file it points at legitimately changes the generated terraform. Everything else in a ~2000-input closure is identical.
The only non-mechanical edit was the agent-state shell test, which walks up
from its own directory with a literal cd ../../../.. — the depth changed by
one. Ran it directly rather than counting segments.
Sweeping the decision log (commit d3ba214)
Reversed the call I had just made and reported — leave docs/decisions/ stale,
because an ADR is a dated record. Asked to sweep them, and the sweep is more
interesting than the policy question was.
Eleven references across seven ADRs. A blind sed would have been wrong on
three:
- 0004 named per-script flakes under
files/scripts/forgh-review-previewandclaude-ops. Those are uv projects — they went tonix/projects/in d44c48e, not tonix/scripts/. One prefix, two destinations. - 0019 linked
signoz-sync-explorer-views.py, deleted in 4a80465 when the explorer views moved onto terraform via the restapi provider. - 0016 linked
bash-command-validator’shook.py, deleted in 09fee6d — which is precisely the removal ADR 0023 documents, two files away in the same directory.
The last two are the point. Rewriting them mechanically converts an obviously-broken link into a plausible-looking broken link, which is strictly worse: the reader now has no signal that the target is gone. Both are plain text naming the commit that removed them, and 0016 links forward to 0023.
Then checked every nix/ path the whole decision log names, not just the ones
I touched. All resolve. Two apparent failures were my checker’s bugs, not the
docs’: uvx.nix in 0003 is prose about the module that ADR replaced, and
nix/modules/hooks.nix was my regex biting a substring out of the upstream
cachix/git-hooks.nix/modules/hooks.nix.
Template drift, upstreamed (python-project-template v1.8.1)
Different repo, same day, and downstream of this morning: regenerating three
projects from the template put ten generated projects on _commit: v1.8.0, and
every one of them hand-patches the same two things after copier update. Ten
copies of one fix is a template bug reported ten times.
The shellcheck directive that never worked. template/.envrc.jinja carried
# shellcheck source=scripts/bootstrap.sh above its source line and SC1091
fired anyway. source= names the file; it does not grant permission to read it.
Shellcheck refuses external sources unless -x or external-sources=true is
set, and external-sources is honored only in .shellcheckrc — inline it
is ignored by design, since a repo could otherwise point shellcheck at arbitrary
paths. So the directive did nothing while looking like it did everything. Now
# shellcheck source=scripts/bootstrap.sh disable=SC1091, keeping the path hint
for tools that do follow sources.
self in the flake. outputs = { self, nixpkgs, flake-utils } never used
self; now { nixpkgs, flake-utils, ... }.
Also ran prek auto-update (ruff 0.16.0→0.16.1, uv 0.11.25→0.12.1, codespell
2.4.2→2.4.3, commitizen 4.16.4→4.17.0, ty 0.0.55→0.0.65 — the pyproject
floors are all >=, so nothing else moved), bumped template_version to 1.8.1
and wrote the CHANGELOG section the release workflow reads.
The uv cache archive that was missing its payload
ctt — the tool that renders all seven parametrizations, and therefore the
thing that proves a template change is safe — could not start:
ModuleNotFoundError: No module named 'copier'
while uv tool install cheerfully reported + copier==9.17.0. Both true. The
tool env held copier-9.17.0.dist-info/ and no copier/ package directory,
and the dist-info’s own RECORD listed 25 copier/* files that were not on
disk.
The cache is where it went wrong. ~/.cache/uv/archive-v0/sMvsudA1h-8_reG_/
contained the dist-info alone. uv hardlinks installs out of that archive, so
every install from it produced a copier that could not be imported —
reinstalling could never fix it, which is why --force twice changed nothing.
The 9.14.3 archive was missing its payload the same way, so this was not one bad
unpack.
uv pip install --no-cache produced a working copier on the first try, and that
is what named the cache rather than the tool env, the Python version, or the
package.
Then uv cache clean copier timed out after 300s on ~/.cache/uv/.lock. lsof
on the lock: nine uv run --script ~/bin/{acl,arxiv}-mcp-server serve
processes, the oldest from Thursday, spread across uv 0.11.32 and 0.12.1. Those
are the pre-packaging PEP 723 launches — servers uv run starts and never
exits, each holding the cache lock for its whole lifetime. Killing them would
have dropped live MCP sessions, so: uv cache clean copier --force, scoped to
the one package. 25 files removed, copier/ present, ctt green across all
seven renders.
What was striking
-
The 2026-07-20 decoupling was correct and stopped one step short. It freed the builder from
local.python.enable, which was right, and left the tools gated on nothing, which read as deliberate because the comment explained the half it had solved. A comment that justifies the change you made is not the same as one that justifies the state you left. -
Undoing
lib.uvsix weeks after adding it is the right call and still worth noticing. It was the correct fix for ambient_module.args.uvLibinjection; the registry simply subsumes the problem it solved. The lesson is not that 7b40430 was wrong. -
I was wrong mid-session about dendritic and said so: I assumed
import-treeauto-import would collapse the ADR 0021 axis-1 curated import list. It auto-imports the aspect definitions; hosts still choose which aspects to import. Dendritic would preserve axis 1, not destroy it. That materially changes the cost side of the decision. -
prekstashes unstaged changes, so a formatting hook’s fix gets rolled back and the “0 / 4 reformatted” counter makes it look like the hook did nothing.git add -Abeforejust check, always. Cost a full cycle. -
nix flake checkfrom~/.configchecks the root flake (devShell + pre-commit), notnix/. Acdin a compound command silently retargeted it twice and produced a scary red herring. -
The repo root being
~/.configis a lint-visible fact, not just a packaging one. Every gitignored directory at that root is a potential first-party module name to any tool that infers from the filesystem, and APFS case-insensitivity widens the collision surface past what a case-exact reading would predict.ipython/is the one that bit;nvim/,zsh/, andnix/sit in the same position. -
Two gates disagreeing is information, not noise. I wrote the ruff failure off as version skew on the first look because “pre-existing and in a file I never touched” was a comfortable story. It survived one round of hand-waving and only fell to reproducing it in a clean copy of the tracked tree.
--no-cacheruled out my first real hypothesis;--show-settingsruled out config; what was left was the filesystem. -
Twice today I asserted a cause for the obsidian-mcp-proxy omission without checking
git log— first “drift,” then “deliberate.” Both were inventions. Two commit dates settled it in one command. -
atlas
drvPathstayedagxwsrv407cyl33fbjbxvglcvjrh4v7iacross every single step, including the consumer conversion — which is what proved the registry-built packages are the same derivations as the old inlinemkProjectcalls, rather than merely suggesting it. -
A per-file-ignore list is where a constraint and a defect become indistinguishable. Both entries look identical in TOML. The only thing separating “this cannot be otherwise” from “I did not fix this” was a comment covering the whole block, and once the block mixes both, the comment is wrong about half its contents. Clearing the table and re-running is cheap and is the only way to know an ignore is still load-bearing — ruff will tell you about an unused
noqa, but never about an unusedper-file-ignoresentry. -
A characterization test only earns its name if it runs green against the code it is characterizing before the rewrite. Writing it after is writing a description of the new implementation and calling it a net.
-
The template convention was discoverable from
.copier-answers.ymlin every existing project, and I hand-rolled apyproject.tomlanyway. The house pattern was one file read away in the directory I was already working in. -
A suppression comment is an argument, and mine outlived its evidence. “Needs a cast at fifty call sites” was measured once, in the worst possible way (let the stubs error everywhere and count), then written down as settled. The real question — how many distinct shapes? — has the answer three, and I never asked it because the comment had already closed the topic. Untyped code at a library boundary usually clusters into a handful of shapes; count those, not the call sites.
-
A file’s own
just checkpasses on files git has never seen. prek only looks at tracked files, so a brand-new untracked test file is invisible to it and six real violations sailed through untilgit add. Staging is not a formality before the gate; it is what makes the gate see the work. -
A waiver written as a constraint stops anyone checking whether it is one.
RUF029sat behind “cannot become sync without changing how they are registered.” One probe falsified it, and the rule had been flagging a real event-loop bug the whole time. Three of the four things I unpicked today were wrong in the comment, not the config — the config was downstream of prose nobody re-read. -
A file-level suppression is a standing waiver for code that does not exist yet. acl’s
ruff: file-ignorewas written for one function and by now covered a blind except 280 lines away. Scope it to the thing that needs it, and if several places need it, that is the argument for one shared guard. -
--fixis not semantics-preserving underunsafe-fixes = true. Ruff turned a zero-arg lambda into a method reference, moving an attribute lookup out of its try block. Both forms are “a callable”; only one degrades. Read autofix diffs on guard code specifically. -
Grep patterns for suppressions need to cover the whole family. Mine had
ruff: ignoreand the codebase also usesruff: file-ignore, so I reported “zero inline suppressions” with one sitting on line 4 of a file I had open. -
A cache branch that never refreshes is a trap, not an optimization. The disk branch loaded a checkout it could never update, so one upstream layout change froze it broken indefinitely. Any “use the local copy if it looks present” shortcut needs the fallback to be the repairing path, and the liveness check has to name what it actually requires — an existence probe on one subdirectory is a proxy that drifts the moment upstream moves anything.
-
Lazily-built indices report failures far from the cause. The bad load succeeded; three of six subcommands then raised at first use. A partial symptom set is a hint that validation and use are separated in time.
-
I nearly hardcoded
("xml", "yaml", "json")from what the directory looked like before the pull. Reading state, then acting on it after an operation that changes that state, is its own small trap. -
Every wrong call today was defended by prose I wrote and never re-read. The
RUF029waiver, the lxml “fifty call sites”, thefrom_repoclaim, and thefiles/scriptsrejection were four instances of the same failure: a judgement made once, written down as settled, then quoted back as evidence. The tell is that each fell to a single command. What made them durable was that a comment reads like a finding. -
Two runs of the same pinned tool version can disagree.
ty0.0.65 in-project andty0.0.65 in the prek hook typed_rundifferently, because the environment they resolve against differs. When a check passes locally and fails in the hook, the version is the first thing to rule out, not the conclusion. And code whose type depends on that is worth rewriting rather than pinning down. -
prek runonly sees staged files, so a passing gate proves less than it looks. The gitleaks finding on a two-year-old test fixture had never been in a staged diff. Whole-tree state and staged-diff state are different questions, and only the second one is whatjust checkanswers. -
prek prints
✓ <workspace>only for workspaces that pass. I counted those lines and concluded discovery had broken, when six workspaces had run and three had failed. Counting successes is not counting attempts. -
fdrespects.gitignore, sofd '\.pre-commit-config\.yaml'found nothing and I briefly believedgit mvhad eaten six tracked files.-Ior a plain shell test is the control. The reason it found nothing was the actual bug — an unanchored ignore pattern — and I filed it as a tool quirk and moved on. A surprising null from a search tool is sometimes the finding. -
A directory’s name is a claim about its contents that nothing enforces.
home-manager/files/means “sources forhome.file” and was 2/21 that; the house rule inAGENTS.mddescribed those two. Names drift silently because no gate ever evaluates them, so the doc keeps describing the founding case long after it became the exception. -
I called the same two files “plain bash” twice — in a code comment and in this journal — while their first line reads
uv run --script. Both times I was writing about them, not reading them. The second was worse: I was writing an inventory whose whole purpose was completeness, and I copied my own earlier error into it. A note written from memory inherits the memory’s bugs. -
A green gate can mean “looked and passed” or “did not look.”
just checkreturned 0 across two brand-new projects whose hooks it never discovered, because an ignore pattern kept their configs untracked. Exit status alone cannot distinguish the two; only the workspace list could, and I read it because the count looked wrong, not because I set out to check. -
Anchoring the pattern was worth doing over
git add -f, which is what the previous six got: the force-add fixes one file and leaves the trap armed for the next project. Seven people-hours of “why didn’t the hook run” are cheaper to prevent with one leading slash. -
A failing test can mean the test is wrong, and that is still a finding. My subcommand test asserted what the parser should do. It doesn’t — a separated flag’s value is indistinguishable from a subcommand to a positional heuristic. Pinning the actual behaviour under an honest name beats both silently fixing it and silently deleting the test.
-
A dependency that is declared but never imported is invisible under PEP 723 and permanent under a lockfile. Packaging is the moment to check imports against declarations, because it is the moment the mistake stops being free.
-
An unexpected drvPath change is a question, not a verdict. A pure rename moved atlas and not athena, which had to mean something non-mechanical had changed. Diffing the closures found it in one string — a doc pointer inside a SigNoz alert body, legitimately naming the renamed file. Two minutes of
nix derivation show | diffconverted “probably fine” into knowing exactly what moved and why. The habit only pays if an unexplained change is treated as unfinished rather than as noise. -
Directory names are the last thing to get renamed and the first thing read.
home-manager/files/scripts/outlived its meaning by two commits today alone, and the stale name is what kept me arguing, this morning, that the uv projects belonged in it. A wrong name does not just fail to inform, it actively supplies a bad premise to the next decision. -
A
cd ../../../..in a shell script is a hardcoded depth that no rename tool and no type checker sees. It is the one edit in a directory move that is not mechanical, and the only honest check is running the thing. -
Repointing a link to a file that no longer exists is worse than leaving it broken. Two ADR links pointed at deleted files; a path-prefix sweep would have “fixed” both into new dead links that now look current, destroying the only signal a reader had. Before rewriting a reference, check the target exists — a rename sweep is a set of assertions about the tree, and an unverified one is just a plausible string.
-
One stale prefix does not imply one destination.
files/scripts/had to becomenix/scripts/for shell andnix/projects/for the two uv projects, because two different moves had passed through the same directory. Sweeps assume a bijection that history rarely provides. -
I wrote a verification loop and it reported two failures that were both my regex’s fault — a substring bite out of an upstream path, and prose about a module that had been deliberately replaced. The check has to be checked, and the tell for a false positive is that the “broken” reference reads correctly in context.
-
I had decided not to touch the ADRs and said so with a reason I believed (dated records should not be rewritten). It was defensible and it was still worth overturning, because the actual content was pointers, not decisions. Stating a judgement plainly enough to be overruled is what let that happen in one line.
-
A directive that names a file is not a directive that permits reading it.
# shellcheck source=...sat above thatsourceline looking like the answer to SC1091 and silencing nothing, because the permission lives in a different directive that is deliberately unavailable inline. Worse than no comment: a plausible annotation stops anyone asking whether the warning still fires. Same shape as theRUF029waiver above — prose that reads like a finding. -
Ten copies of the same hand-patch is one template bug reported ten times. Nobody upstreamed it because each individual fix costs ten seconds. That
copier updatereintroduces it every time is the signal; the annoyance is not. -
uv tool installcan report success for a package it did not install.+ copier==9.17.0, dist-info written,RECORDnaming 25 files that are not there. uv verifies against the archive it hardlinks from and never against what that archive actually contains, so a corrupt archive is a permanently successful install of nothing.--no-cacheis the one-command discriminator between “this package is broken” and “my copy of it is.” -
A long-lived
uv run --scriptprocess holds the uv cache lock the entire time it runs. Nine MCP servers, the oldest four days old, madeuv cache cleanunrunnable — a maintenance command blocked indefinitely by processes doing nothing. A second, quieter cost of PEP 723 launches for servers, on top of the per-invocation re-resolution the packaging work fixed this morning.
Decision: dendritic, not now
Not adopting it. It is a ~50-module restructure whose main payoff is uniformity,
bought against a config that works and is documented by ten-plus ADRs. Revisit
when a NixOS host starts sharing features with the Darwin hosts, since
one-file-contributes-to-both is where it stops being cosmetic.
hosts/obsidian-mcp is that host today and shares nothing.
Worth recording that the Discourse thread is near-uniformly positive, which is weak evidence rather than a clean bill of health — it is the pattern’s own announcement thread and self-selects for adopters.
Still open
Wrong on both counts, and struck rather than deleted because the error is the point. Both arefiles/scripts/still holds two loose bash scripts,parse-historyandparse-claude-tools… Neither is Python and neither wants a uv project.uv run --scriptwith PEP 723 headers. Packaged in 254126b; see above.binScriptsno longer exists — every~/binentry is a derivation now.mainandorigin/mainare level. The pushes throughout were manual, mine; I twice reported them as unexplained after asserting a commit count I never re-derived. Nothing in the repo pushes automatically.prek updaterewrites rev pins but does not commit or push.acl-mcp-server authorandpapersare broken on atlas, pre-existing.~/.acl-anthology-data/datahasxml/andyaml/but nojson/, so the people index and the event index both fail to build.paper,bibtex,readandlistare fine. Verified pre-existing:get_personwas equally unguarded at d4256a1. The proximate cause is_load_anthology’s disk-detection heuristic —data_dir.exists() and (data_dir / "xml").exists()accepts a half-populated checkout and takes the load-from-disk branch instead offrom_repo, which would build the indices. Fixing it means deciding whether to re-clone the user’s data dir, so it is a separate call.packages.x86_64-darwin.<uv project>is now defined for all four systems. Evaluation only, andnix flake checkis green, but it is a real widening of what athena’s entry names versus before.- athena still needs a real
hm switchto validate, same caveat as 2026-07-20. - Whether other loose
.pyfiles in the tree have the same latent isort ambiguity. OnlyIPythonerrors today, but the collision class is structural and nothing prevents the next one. - Why the uv cache archives lost their payload is unexplained. Two copier
versions kept their dist-info and lost
copier/; nothing else in the cache was audited. If it recurs, the lock contention above is the first suspect — a--forced clean racing an install would produce exactly this shape, and I ran one. - python-project-template v1.8.1 is out. PR #13 merged after all six checks
passed (four build matrix legs, the ctt render, and the
copier updatemigration test), andrelease.ymlcut thev1.8.1tag and release from the CHANGELOG section. What remains is runningcopier updateacross the ten downstream projects so they stop carrying the hand-patches — a separate pass, and the one where the ARG_MAX bug documented in that repo’sCLAUDE.md(copier-org/copier#2244) will bite on any project living inside a large gitignored tree.
Closed since first writing this entry: the root flake’s pre-commit-run
failure, which this entry originally listed here as pre-existing ruff skew. It
was neither pre-existing in cause nor a skew — see commit 2ef6930 above. All
three gates (just check, root nix flake check, nix/ nix flake check)
are green, the root one for the first time this session.
Related
- Dotfiles Host Profiles via Private Flake — the project.
- 2026-07-20 lib.uv Refactor and athena 26.05 Pin — the direct predecessor;
this supersedes its
lib.uvextension and completes its gating story. - Dendritic Pattern Borrows AOP Vocabulary But Realizes SPL Composition — the framing note the decision rests on.
- 2026-07-19 Capability Flags Were Never Tested — why the contract test in
tests/capability-flags.nixexists to be extended at all. - Migrate uvx.nix to uv2nix — where
mkUvProject/uvLiboriginated.