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_papersformat 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.nix still said the ~/bin python scripts “declare their deps in PEP-723 headers.” False since this morning — binScripts is two bash scripts.
  • _run’s in-place narrowing typed differently under two ty environments. The project’s own uv run ty passed; the pre-commit hook’s ty joined the branches to object. Same pinned version, 0.0.65 both. Annotating the result Any makes it independent of how a given typeshed declares isawaitable and asyncio.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 run only 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-tools declared claude-code-log and 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-history caught bashlex.errors.ParsingError while importing only bashlex. 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 named json, 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/ for gh-review-preview and claude-ops. Those are uv projects — they went to nix/projects/ in d44c48e, not to nix/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’s hook.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.uv six weeks after adding it is the right call and still worth noticing. It was the correct fix for ambient _module.args.uvLib injection; 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-tree auto-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.

  • prek stashes 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 -A before just check, always. Cost a full cycle.

  • nix flake check from ~/.config checks the root flake (devShell + pre-commit), not nix/. A cd in a compound command silently retargeted it twice and produced a scary red herring.

  • The repo root being ~/.config is 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/, and nix/ 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-cache ruled out my first real hypothesis; --show-settings ruled 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 drvPath stayed agxwsrv407cyl33fbjbxvglcvjrh4v7i across every single step, including the consumer conversion — which is what proved the registry-built packages are the same derivations as the old inline mkProject calls, 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 unused per-file-ignores entry.

  • 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.yml in every existing project, and I hand-rolled a pyproject.toml anyway. 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 check passes 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 until git 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. RUF029 sat 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-ignore was 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.

  • --fix is not semantics-preserving under unsafe-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: ignore and the codebase also uses ruff: 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 RUF029 waiver, the lxml “fifty call sites”, the from_repo claim, and the files/scripts rejection 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. ty 0.0.65 in-project and ty 0.0.65 in the prek hook typed _run differently, 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 run only 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 what just check answers.

  • 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.

  • fd respects .gitignore, so fd '\.pre-commit-config\.yaml' found nothing and I briefly believed git mv had eaten six tracked files. -I or 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 for home.file” and was 2/21 that; the house rule in AGENTS.md described 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 check returned 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 | diff converted “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 become nix/scripts/ for shell and nix/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 that source line 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 the RUF029 waiver 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 update reintroduces it every time is the signal; the annoyance is not.

  • uv tool install can report success for a package it did not install. + copier==9.17.0, dist-info written, RECORD naming 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-cache is the one-command discriminator between “this package is broken” and “my copy of it is.”

  • A long-lived uv run --script process holds the uv cache lock the entire time it runs. Nine MCP servers, the oldest four days old, made uv cache clean unrunnable — 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

  • files/scripts/ still holds two loose bash scripts, parse-history and parse-claude-tools … Neither is Python and neither wants a uv project. Wrong on both counts, and struck rather than deleted because the error is the point. Both are uv run --script with PEP 723 headers. Packaged in 254126b; see above. binScripts no longer exists — every ~/bin entry is a derivation now.
  • main and origin/main are 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 update rewrites rev pins but does not commit or push.
  • acl-mcp-server author and papers are broken on atlas, pre-existing. ~/.acl-anthology-data/data has xml/ and yaml/ but no json/, so the people index and the event index both fail to build. paper, bibtex, read and list are fine. Verified pre-existing: get_person was 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 of from_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, and nix flake check is green, but it is a real widening of what athena’s entry names versus before.
  • athena still needs a real hm switch to validate, same caveat as 2026-07-20.
  • Whether other loose .py files in the tree have the same latent isort ambiguity. Only IPython errors 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 update migration test), and release.yml cut the v1.8.1 tag and release from the CHANGELOG section. What remains is running copier update across 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’s CLAUDE.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.