database.rules.json carries YOUR_FIREBASE_UID on purpose -- this repo is
public and the uid is the one value that must not be in it. But nothing
stopped anyone publishing the template as-is, and rules pinned to a uid that
matches no account deny every read and write. The symptom is every device
failing at once with a bare permission error, which reads like an outage
rather than a bad paste.
render_rules.sh substitutes the uid from ~/.config/crdt-sync/firebase.json
and fails closed on anything unsafe: a surviving placeholder, missing
`auth != null`, a missing uid pin (that one matters -- `auth != null` alone
lets any stranger who self-registers through the public Web API key read
everything), or output that is not JSON.
Verified: its output is byte-identical to the deployed
~/.config/crdt-sync/database.rules.json, and the live database still answers
an unauthenticated read with 401. All three failure paths exercised.
Also corrects the README, which described the in-repo copy as "the deployed
rules, with the real uid filled in" -- it never was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
lyricanki's Linux desktop cannot use google_sign_in (no Linux
implementation) and its loopback replacement is dark until a Desktop OAuth
client exists. Hiding the button there makes the screen silently offer less
on one platform, which reads as a bug.
googleUnavailableReason renders a *disabled* button plus the reason. That is
not the "control that can never succeed" trap: the trap is a live-looking
button that reports a fake cancellation, and a null onPressed cannot be
tapped at all. Null stays the default, so every existing host is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
An optional '' default reads as "this parameter is usually irrelevant", and
it made `avoid_redundant_argument_values` fire in the first app to pass ''
explicitly -- pushing that app toward deleting the argument, which is exactly
the line that must stay for when the Desktop client id arrives.
Requiring it forces each app to state which halves it configured. Passing ''
still means "not configured"; it just has to be said out loud.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Five apps had copied google_sign_in_backend.dart; home_inventory's copy was
byte-identical to todo's. lyricanki was about to become the sixth, which is
the mistake this package exists to prevent. So the plugin call moves here.
Desktop is the new half. google_sign_in ships android, ios and web
implementations only -- there is no google_sign_in_linux -- so lyricanki's
real GTK build could never have used the plugin, and "one tap" would have
meant "one tap on the phone, type a password on the PC". The OAuth loopback
flow covers it: bind a socket, open the system browser, catch the redirect,
exchange the code with PKCE. Both halves return a Google ID token of the same
shape, so signInWithGoogle cannot tell them apart.
googleSignInSupported takes the client id rather than reading a const. In
todo the emptiness check was a second conjunct at the call site, and moving
the fetcher here without it would have let a forgetful app ship a button that
always reports "cancelled" -- todo's own file documents that failure. Taking
the value as an argument makes the check impossible to drop.
The decision (which flow, or none) is pure and covered; the two one-line
delegations that reach a platform channel and a real browser are the only
suppressions. 83 tests, 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Two different git refs for the same package name cannot coexist in one
resolution, so an app taking both sync_settings_ui and crdt_sync_flutter --
which is the intended pairing, and what lyricanki now does -- failed version
solving while this stayed pinned to v0.10.0.
v0.11.0 only widens what FirebaseAccount.tryParse accepts, so nothing here
changes behaviour: 29 tests green, analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Deleting prompts/dopamine-ux-01-motion-tokens.md (the completion protocol) left
00-INDEX.md's "Distributed copies" table citing a path that no longer exists, so
verify_prompts went from 3 pre-existing failures to 4. Exactly the dangling
anchor 44bc12b fixed after the doc split.
Asserts the path ABSENT rather than dropping the row, which keeps the check
two-directional: a finished prompt reappearing in a repo is itself a failure,
because the next session would re-run work that is already done. Verified both
ways -- recreating the file fails the check, removing it passes.
This recurs for 02-09, so the comment says to move a row as each completes.
Back to the same 3 failures as before this session, all in ~/screen-locker and
all pre-existing (prompts 04/05).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
flutter_secure_storage 9 pins flutter_secure_storage_windows 3, which pins
win32 5 -- and share_plus 13 needs win32 6. Any app depending on both
(lyricanki, the first consumer) failed version solving outright, and pub's
suggested way out was downgrading share_plus, which would undo a just-shipped
Android share fix.
home_inventory already runs 10.3.1, so this moves toward the fleet rather than
away from it. 35 tests still green on the newer plugin.
Released as v0.1.1 rather than re-cutting v0.1.0: that tag is already pushed,
and moving a published tag breaks anyone who resolved against it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
All four `done` conditions hold: motion.md exists at 121 lines, the 250-line
cap and palette_check both exit 0, and both tags are cut (design_system-v0.2.0,
web_ui-v0.3.2).
Deletes the distributed prompt copy per the protocol in 00-INDEX.md -- a
finished prompt left in a repo is indistinguishable from a pending one, and the
next session re-runs it. The canonical copy in dopamine-ux/ stays as the record.
Prompt 01 gated the other eight; 02-09 are now unblocked, and 06/07/08 have the
literal tag refs they were waiting on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.
unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.
Three things worth stating, because they are the parts easy to get wrong:
- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
preference. Suppressing animation is not a request to lose confirmation that
a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
a meal, not on hitting a calorie target -- the latter rewards eating less
rather than recording honestly, and corrupts the app's own data.
gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.
Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`crdt_sync` is pure Dart so it can run from CLI tools and headless systemd
jobs, which means it takes closures instead of depending on
flutter_secure_storage and google_sign_in. Every Flutter app then wrote the
same ~390 lines to supply them, and five apps ended up carrying near-identical
copies of firebase_backend.dart.
Those copies drifted. `todo` gained an opt-out flag and a seeded-session path
that home_inventory and wake_alarm never received, so the same bug was fixed
in one copy and left live in the others -- which is the actual cost being paid
here, not the line count.
This package owns that glue once: the keystore adapter, the persisted
per-install device id, the account store and a one-call bootstrap. A new app
needs a SyncApp const and `openSync(kApp)`.
The wrapper routes are a parameter rather than a constant, because todo's copy
hardcoded its own `~/.config/todo/firebase_auth.json`. An extraction only one
app could adopt would be worse than the copies.
Also documents what made this hard to find: crdt_sync_dart's README covered
only the *old* GitHub transport and never mentioned the Firebase surface at
all, and sync_settings_ui -- a complete drop-in settings screen -- had no
README whatsoever.
The five existing apps stay on their copies and on crdt_sync_dart-v0.10.0.
Migration is forward-only and those fallbacks are load-bearing; converging
them is a separate task, and the README says so.
35 tests, 100% line coverage, no suppressions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Both the Google one-tap path and the seeded-session path deliberately write
`FirebaseAccount(email: ..., password: '')` -- on those devices the refresh
token in the keystore is the credential, and there is no password to store.
`tryParse` rejected exactly that blob. So a device signed in with Google
wrote an account marker it could never read back: every later launch saw
"not configured", fell through to the mirror, and left a perfectly good
refresh token unused in the keystore beside it.
Only widens what parses, so no caller that previously got an account back
can regress. Every call site already guards `password.isEmpty` and passes
null for it -- meaning "no password on this device" -- which is precisely
the case this now lets them see. An empty *email* stays rejected: it
carries no identity at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
While trimming that file under the cap I had rewritten the parametrize as
`[*lg._EXPECTED_ISSUERS]`, which asserts only that the module agrees with
itself: deleting a spelling from _EXPECTED_ISSUERS would have removed the case
that tests it and left the suite green.
Confirmed by mutation rather than by inspection -- dropping the bare
"accounts.google.com" from the module now fails
test_both_google_issuer_spellings_are_accepted[accounts.google.com], where
before it would have passed. Google uses both forms, so accepting both is a
real contract worth pinning.
432 passed, still 100%, verified with HOME redirected at a temp directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
CI caught this: 8 tests failed on the runner with "/home/runner/.config/
crdt-sync/password could not be read". They passed here for the worst possible
reason -- that file exists on this machine, so the tests were reading the
user's actual sync password.
_CONFIG is a real FirebaseConfig, and link_google() calls
config.read_password(), which falls back to PASSWORD_FILE when given no path.
The linking tests now pass a MagicMock(spec=FirebaseConfig) with read_password
stubbed, which is what the one test that already asserted on the password was
doing; that test now shares the helper instead of keeping its own copy.
Adding the helper pushed the file to 265 lines, so the token-inspection tests
move into test_tool_link_google_token.py -- a three-way split matching the
module's three concerns (inspect / link / CLI) at 196, 82 and 223 lines.
Also fixes a mypy finding in the seed_session ordering test, which used
`list.append(...) or "value"` to record call order in a lambda. append returns
None, so the expression only worked by accident; replaced with named functions.
Verified the way the earlier local run could not: HOME pointed at an empty
temp directory, reproducing the runner. 432 passed, 1333 statements and 234
branches at 100%. ~/.config/crdt-sync/password untouched (mtime still Aug 4).
Worth keeping as a rule: for code that reads ~/.config, a green local run
proves nothing unless HOME is redirected first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Per the file's own closing instruction -- a finished prompt left in the repo
is indistinguishable from a pending one, and the next session would re-run it.
1. web-ui-tests green (53f2816). Not a stale dist/: 16 files differed only
by a trailing newline tsc does not emit, left by end-of-file-fixer before
the dist/ exclude existed, and masked locally by tsc -b's incremental
cache.
2. All of tool/ at 100% branch coverage, with source = ["crdt_sync", "tool"]
in effect (6d64f9e). 259 -> 432 tests.
3. The three dirty items resolved: seed_session committed (9d638fb),
firebase-debug.log gitignored and deleted (e0eef3d), staged/ applied into
~/.claude (e0cf24a there) and removed.
4. The design-system skill pointer corrected, in ~/.claude e0cf24a.
Two things this leaves open, deliberately, neither part of the four tasks:
~/.claude/scripts/finish.sh is 309 lines and over the cap (it blocks nothing
today -- the hook gates staged files only), and phone_deploy.sh's
build/install/launch tail is still unexercised because it needs a real APK
install on the phone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Finishes the tool/ coverage task. seed_session.py is the seventh and last
module, split into seed_apps and main() at 224 and 222 lines.
The tests pin the two structural decisions the module's docstring argues for,
since neither is visible from its output: each app gets its own exchange and
its own credential store (one session copied across them would have every app
refreshing the same rotated token and invalidating the others), and
verification runs as a *second pass* after all sessions exist -- checking each
app as it is written would pass one that a later exchange then invalidates,
which is the exact failure being guarded against. Asserted by recording the
call order and requiring seed,seed,verify,verify.
Also covers the partial-failure path, which is the difference between a
copy-pasteable rerun and a hunt through ~/.config: SeedError carries the apps
that verified, and the rerun line lists the remaining ones and only those.
With every module at 100%, the config flip lands last as planned:
addopts += "--cov=tool"
source = ["crdt_sync", "tool"]
Done condition met -- 432 passed, 1333 statements and 234 branches at 100%,
tool/__init__.py included. `import crdt_sync` and `python3 -m
tool.google_id_token --help` both still work after the ruff autofixes, and
`pre-commit --all-files` is clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Sixth module of the tool/ coverage task, split into token inspection/linking
and _read_token/main() at 249 and 224 lines.
The failure this script exists to prevent is silent -- linking onto a new
account succeeds at the auth layer and is then denied every read and write by
rules that pin one uid -- so the two central assertions are made on the
request body rather than inferred from the response: the existing account's
idToken is always sent (omitting it turns accounts:signInWithIdp into "sign in
or sign up"), and a mismatched localId is a hard error whose message names the
console unlink step.
Also pins the deliberate exception handling in main(): LinkError, ConfigError
and FirebaseAuthError each return 1, while an unexpected RuntimeError is left
to propagate. That is the stated reason the handler names three types instead
of catching Exception -- a tidy "FAIL" line for an unanticipated bug would
hide it -- and it is only a real guarantee if something asserts it.
crdt_sync 365 -> 400 passed, still 100%. 6 of 7 tool/ modules done; only
seed_session.py remains before the config flip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Fifth module of the tool/ coverage task, split into the GitHub-reading half
and main() at 216 and 248 lines.
This is a one-shot migration whose worst outcome is reporting success while
having moved the wrong bytes, so the tests pin the decisions that prevent
exactly that: reading blobs by sha rather than by path (the contents API
would return whatever is at that path *now*, which need not be what the tree
listing described), refusing a truncated tree instead of migrating part of
the repo, never fetching a skipped path, emitting the report before the first
write, and failing the run when the byte-for-byte read-back disagrees --
including the partial case, where one bad file out of two is a failed
migration rather than a 50% success.
Also fixes the S105 finding the previous commit's preflight tests carried:
`_PASSWORD` trips the hardcoded-credential rule on the binding name, the same
trap this task's notes already record. Renamed rather than suppressed. The
blanket rename briefly caught the module attribute `_PASSWORD_FILE` too,
which the suite caught immediately.
crdt_sync 333 -> 365 passed, still 100%. 5 of 7 tool/ modules done; the two
remaining are link_google.py and seed_session.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Fourth module of the tool/ coverage task, and the first of the four large
ones. Split into checks and loaders/main() up front rather than discovering
the 250-line cap afterwards -- 245 and 225 lines.
Every check is asserted on its *message*, not merely on having raised. The
whole reason this tool exists is that the misconfigurations it catches are
indistinguishable once data has started moving, so a check firing with the
wrong explanation sends the reader to the wrong place. That includes the
wrong-region 404, which answers with a correctUrl body rather than an error
and would otherwise pass preflight, pass sign-in against a different host
entirely, and only break mid-migration.
main()'s stated contract is pinned too: cheapest-first ordering, and that it
short-circuits -- a typo must be reported without a network round trip, so
the later network checks must not run at all.
The module's config paths are module-level constants pointing into the real
~/.config/crdt-sync, so the fixture redirecting them at tmp_path is autouse:
a test that forgot it would read and report on the user's actual credentials.
Confirmed afterwards that ~/.config/crdt-sync was not touched.
crdt_sync 293 -> 333 passed, still 100%. 4 of 7 tool/ modules done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Keeps the handoff accurate mid-task: the module table now carries per-file
status and commits, the ordering note no longer sends a reader back to work
already done, and the baseline is 293 rather than the original 259.
Adds the two traps this session hit that the prompt's own list did not carry
-- `ruff check` alone is insufficient because the hook applies unsafe fixes
CI then rewrites the file with, and S105/S106 match the binding name rather
than the value -- plus the observed ~1.3 test-lines-per-module-line ratio,
which means each of the four remaining modules needs a planned split rather
than a discovered one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Third module of the tool/ coverage task, and the last small one.
Patched at the module's own boundary (firebase_client_for, FirebaseConfig.load
and sync_log), so nothing touches Firebase. The assertions are deliberately
about the *shape* handed to sync_log rather than about sync_log working --
test_sync.py already covers the latter. This tool exists so
crdt_sync_dart/tool/interop_check.dart has a Python-written record to find, so
the things worth pinning are the ones that would make the two halves silently
miss each other: the _interop/devices prefix, the pydev device id, the py-rec
id with its exact value and Hlc, the library's own codec rather than a local
reimplementation, and the in-memory revision store that keeps the seed
repeatable after the Dart side deletes both devices' data.
crdt_sync 286 -> 293 passed, still 100%. 3 of 7 tool/ modules done; the
config flip stays last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
`crdt-sync pre-commit` went red on the previous two commits: pytest is only
referenced in annotations in the _cli split, so TC002 moves it into the
type-checking block.
Missed locally because I ran `ruff check` directly, while the hook passes
`--fix --unsafe-fixes --exit-non-zero-on-fix`. TC002's fix is classed unsafe,
so a bare `ruff check` reports nothing and CI still rewrites the file. The
lesson generalises past this file: for this repo, `pre-commit run --all-files`
is the check, not `ruff check` -- the same trap the prompt already recorded
for re-export pruning.
Verified after the fix: 18 tests, tool/google_id_token.py still 100% branch,
and `pre-commit run --config crdt-sync/.pre-commit-config.yaml --all-files`
clean end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Second module of the tool/ coverage task. No network and no browser: the
callback server is replaced wholesale so consent resolves instantly, and
requests.post is mocked at the same boundary test_firebase_auth.py uses.
All six TokenError paths are covered, since each names a different
misconfiguration and the module docstring exists because they are easy to
mistake for one another -- notably the fixed-vs-free port distinction, where
a Web client matching redirect URIs exactly fails with redirect_uri_mismatch
before the account picker ever appears. Also asserts the server is closed on
the failure path, since a leaked listener makes the next run fail to bind.
Split across two files because the cap hook rejected the first attempt at 273
lines: the flow tests here, main()'s argument parsing and exit code in
_cli.py, which imports the shared fixture values rather than redeclaring them.
That is the gate doing its job on this session's own output.
Three things the tooling caught, all fixed rather than suppressed:
- S105/S106 match on the binding *name*, not the value, so any constant
called SECRET/TOKEN/PASSPHRASE trips them however obviously fake it is.
Renamed around the rule the way test_firebase_auth.py names _ID_1.
- mypy rejected the previous commit's log_message test for asserting on the
return of a `-> None` function -- a tautology. Rewritten to make a real
request and assert stderr stays empty, which is the only thing that
distinguishes the silenced handler from the stock one.
- ruff pruned the imports that moved out during the split; verified it took
only those, and that the suite is still green afterwards.
crdt_sync 268 -> 286 passed, still 100%. Both modules verified at 100% branch
individually; the config flip stays last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
First module of the tool/ coverage task. It is the one the task was raised
for: _start_callback_server's runtime path had never been exercised, and it
is the only tool/ module with no network dependency, so it needs no mocking.
Driven over real HTTP against 127.0.0.1 on a port the module picks, rather
than with a faked request object. The branches worth protecting only exist
once a socket is involved -- particularly the /favicon.ico fetch that must be
answered without being recorded, since treating it as the callback retires the
server before Google ever redirects and surfaces to the user as a consent
timeout. Both serve-loop exits are covered: a result arriving, and the
deadline passing (monkeypatched negative rather than waiting out 300s).
Writing them surfaced a teardown race, not a bug in the module: calling
server_close() while the worker is still inside handle_request() pulls the
socket out from under it and raises on the thread. Invisible normally and
deterministic under --cov, whose tracer widens the window -- so the tests
retire the thread through the loop's own exit condition before closing, via
one _retire helper. Verified over 6 consecutive --cov runs.
crdt_sync 259 -> 268 passed, still 100%. tool/ is not yet in the coverage
source, so the config flip stays last, per the prompt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Keeps the prompt as the handoff artefact it was written to be, since task 2
(all of crdt-sync/tool/ at 100% branch coverage) may outlast a session and the
file is what stops the next one redoing finished work. Not deleted, per its
own instruction to strike through instead of removing while any task is open.
The finished sections are rewritten rather than merely struck through, because
the prompt's diagnosis was wrong in two places and a future reader would
inherit the error: web-ui-tests was failing on a trailing newline that tsc
does not emit (not a stale dist/), masked locally by tsc -b's incremental
cache; and README.md still holds the per-stack design-system patterns -- what
moved out was the operability and screen-size material.
Also records what the ~/utils working tree cannot show: tasks 3 and 4 edited
~/.claude, committed there as e0cf24a. And notes finish.sh at 309 lines as an
open finding outside the four tasks -- it blocks nothing today, since the hook
gates staged files only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
`web-ui-tests` has been red since 6714d13 and the cause was not a stale dist/:
the content is byte-identical. Every one of the 16 files differed only by a
trailing newline that tsc does not emit.
Where the newline came from: at 03bf1ed the subproject config had no exclude,
so pre-commit's end-of-file-fixer appended a newline to each generated file.
eaa5683 added `exclude: ^web_ui/dist/` to stop that recurring, but did not
revert the newlines already committed -- leaving a dist/ that no `pnpm build`
can ever reproduce, and therefore a job that could never go green.
It looked fine locally because `tsc -b` is incremental: with a warm
tsconfig.tsbuildinfo it skips emit entirely, so `pnpm build` was a no-op and
printed no diff. CI has no cache, always emits, and always failed. Deleting
tsbuildinfo reproduces the failure locally in one command.
Verified: after deleting tsbuildinfo and all of dist/, a from-scratch
`pnpm build` reproduces exactly what is committed here -- `git diff
--exit-code -- dist` is clean, which is the assertion CI makes. pnpm lint
passes and pnpm coverage is 84 tests, 100% statements/branches/functions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
crdt-sync/firebase-debug.log had been sitting untracked since 2026-08-11. The
file itself is disposable tool exhaust, but it not being ignored is the actual
bug: the CLI rewrites it on every failed command, so it reappears in
`git status` indefinitely and is one stray `git add .` away from landing a
1931-line log in the history.
Covers the sibling logs the same CLI writes (firestore, database, and the
rotated firebase-debug.N.log form), since they appear the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
todo used to be grouped with the Flutter-only apps that authenticate in-app
on the phone and therefore need no desktop session. That stopped being true
when it grew a desktop wrapper (lib/desktop/wrapper_server.dart) with its own
Firebase REST client, which needs a seeded session exactly like the Python
daemons do.
Both files the new comment cites were confirmed present today. The credential
cache at ~/.config/todo/firebase_auth.json has the same shape as every other
entry -- FirebaseCredentials.fromJson in crdt_sync_dart parses the same
{id_token, refresh_token, expires_at} that credential_store_for writes -- so
the only thing that was missing is the DEFAULT_APPS entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
The cap work is done, but four things were deliberately left and the session
that found them hit the length gate at 1159 turns. Written as a self-contained
prompt so a fresh session can pick them up without re-deriving anything:
1. web-ui-tests is red (stale web_ui/dist/, needs `pnpm build`) — red since
6714d13, which predates the cap work.
2. crdt-sync/tool/ has no coverage. Scoped honestly: the trigger was
_oauth_callback.py, but `source = ["crdt_sync"]` means adding `tool`
pulls in 7 modules / ~1350 lines, and this repo forbids omitting packages
from coverage. Ordered so partial progress is useful, with the config
flip last so it cannot block unrelated commits.
3. The three dirty working-tree items — including seed_session.py, which is
real intentional work (adds `todo` to DEFAULT_APPS), not junk to discard.
Its claim that ~/todo grew a desktop wrapper was verified today.
4. The unified-design-system skill still describes README.md as holding the
per-stack patterns, which moved to operability-patterns.md.
Carries the traps that cost time this session: run the CI-pinned ruff 0.15.2
rather than whatever `ls | head -1` finds, and use `pre-commit --all-files`,
which is what catches the autofixer pruning re-export imports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
operability.md pointed at README.md#pointer-free--small-screen-per-stack-patterns,
written before that section was moved out of README into
operability-patterns.md. The link now targets the file that holds the content.
My earlier link check only validated the *file* part of each link and silently
ignored the fragment, which is why this survived. Re-checked with the anchor
compared against the target file's actual headings; all links in the directory
now resolve, fragment included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
All 46 files are under the cap and the gate is in place. The survey figures in
the body are the 2026-08-14 snapshot — they were already stale when this
started (39 listed, 46 actual) — so the note says to re-run the gate rather
than trust them, and keeps the rest as the record of what was asked for.
Also picks up a pre-existing markdown-formatter change to the file that was
sitting unstaged in the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
My local pre-commit cache holds three ruff versions and I had been resolving
`ls ... | head -1`, which picks 0.14.5 — CI pins 0.15.2, and the newer version
flags eight things the older one does not (unused imports left behind in
_syncargs.py and tool/_oauth_callback.py, import ordering, an empty
TYPE_CHECKING block). Re-ran against the pinned binary; `ruff check` is now
"All checks passed!" under the same version CI uses.
259 tests at 100% branch coverage, `pre-commit run --all-files` clean for this
config, and `python3 -m tool.google_id_token --help` still runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
The hook entry was an absolute /home/kuhy/utils path, copied from a config
written for ~/.claude where it only ever ran locally. On an Actions runner
that is exit code 127 — "No such file or directory" — so gatelock-precommit
and crdt-sync-precommit failed on every commit of this refactor while the
hook silently checked nothing. Now repo-relative, matching the sibling hooks
(`entry: scripts/check_subproject_scope.sh`): pre-commit always runs from the
git root, so it resolves the same in both places.
Fixing that made the CI configs runnable locally, and `--all-files` then found
what per-file runs had missed — ruff pruning imports that exist *for* their
re-export. `import crdt_sync` was broken three separate ways (FileSyncStateStore,
encode_key, FileCredentialStore). Each affected module now states an explicit
__all__ so the autofixer cannot take them again, and the two files that pushed
past the cap once __all__ was added are split further:
_firebase.py 260 -> 247 + _fberrors 25
_sync.py 257 -> 188 + _syncargs 91
Test patch targets followed the code they patch: gatelock._outputs.shutil ->
_xrandr.shutil, gatelock._arbiter.Path -> _claims.Path.
gatelock and crdt-sync both pass `pre-commit run --all-files` in full;
389 and 259 tests at 100% branch coverage; the root gate exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Requirement 4 of "make it permanent": the pre-commit hook only ever sees the
files in a commit, so it cannot catch a violation that arrives by a path the
hook never ran on — a merge, a push from a machine without hooks installed, a
rebase that resurrects a file. This runs `check_file_length.sh --all` over the
whole tree on every push and PR to main.
Deliberately no `paths:` filter, unlike the other ten workflows: the cap
applies to every file in the repo, so scoping this job to a subdirectory is
the one change that would reintroduce exactly what it exists to catch.
Added last, because until the final doc split it would have failed on push.
Verified against a clean checkout of HEAD in a throwaway worktree — the exact
command the job runs exits 0 — and the commit gate re-tested in both
directions after file_length's own internals were refactored: a staged
261-line file fails (exit 1, no commit object), a 200-line one commits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
tokens.md 260 -> 219 + operability.md 69
nielsen-audit.md 258 -> 154 + shipped-record.md 122
README.md 257 -> 172 + operability-patterns.md 95
Worked the requested order rather than cutting straight to a split.
(a) Automate: the categorical ramp's four constraints were restated in prose
with the measured margins hand-copied (ΔE 34.6/26.0/25.6/27.4, etc).
ramp_check.py prints every one of those numbers and fails if any
regresses, so the prose now points at the command instead of duplicating
its output. The two consequences the numbers do not state — why the ramp
is mid-toned, and why lightness falls monotonically — stay in prose.
(b) Dedup: measured, not assumed. Zero paragraphs are shared between the
three files; README already delegates the rules to tokens.md and shows
only per-stack code. Nothing to remove.
(c/d) Split on meaning: tokens.md is "frozen values", so the behavioural
requirements that *use* those tokens moved to operability.md, with the
per-stack code for them in operability-patterns.md. nielsen-audit.md is a
live grid, so completed phases moved to shipped-record.md — following the
existing phase3-record.md convention, not a new one.
The focus-ring token row had to stay in tokens.md: palette_check.py parses
that file, and moving the row made the check fail with "MISSING md:focus-ring"
— a token it cannot see is a token nothing compares across the four stacks.
The rationale lives in operability.md and the row links to it.
palette_check and ramp_check both at 0 failing checks; every relative link
between the docs resolves.
`bash scripts/check_file_length.sh --all` now exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
sync_settings_widget.dart 68 — the public StatefulWidget and its callbacks
sync_settings_sections.dart 132 — the two sections the screen composes
sync_settings_screen.dart 192 — the State: loading, connecting, build
Uses `part`/`part of`, not imports: _FirebaseSection and _BackupSection are
library-private, so a sibling file cannot import them at all — extracting them
as ordinary files would not compile.
Two shapes tried and rejected on the way: an `extension _BackupActions` on the
State (an extension cannot call the protected setState), and moving build()
out of the class (it is an @override and has to stay). Both reverted from HEAD
rather than patched around.
flutter analyze clean, 29 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
firebase_auth_rest.dart 333 -> 238 + firebase_credentials 71
+ firebase_auth_error 31
firebase_client.dart 283 -> 207 + firebase_keys 60 + firebase_errors 19
sync.dart 259 -> 141 + sync_state 121
Mirrors the split just made on the Python side, module for module, so the two
implementations stay legible against each other: credentials with their store,
key escaping with its escape table, the error type with the response parser
that builds its message.
Every new file is added to lib/crdt_sync.dart. A src/ file that is not in the
barrel compiles and tests green while silently dropping the symbol from every
consumer's import, so the export list is part of the split, not an afterthought.
280 tests pass and `dart analyze` is clean — including the four consumers
(firebase_config, secure_credential_store) whose imports had to follow the
moved types.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_sync.py 456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34
_firebase_auth 412 -> 242 + _credentials 146 + _autherrors 69
_firebase.py 336 -> 250 + _fbkeys 100
google_id_token 316 -> 232 + tool/_oauth_callback 105
_github.py 255 -> 238 + _gherrors 30
revision_of became its own leaf module because both the push and pull halves
need it; without that the two would have imported each other. _fbkeys holds
the RTDB key escaping — the escape tables moved with the functions that read
them, since the pair has to round-trip exactly or a device silently stops
seeing its own writes.
crdt-sync/tool/ is outside the coverage source, so the split there has no test
net: verified by running it the documented way instead
(`python3 -m tool.google_id_token --help` prints its usage, and
`from tool.google_id_token import TokenError, fetch_id_token` — what
seed_session.py does — still resolves).
An AST pass over each pair caught what the suite would not have: _is_revoked
needs the error type, so it stayed with it rather than following the
credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be
carried to their new callers.
259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and
firebase-debug.log are pre-existing local changes and are deliberately not in
this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_arbiter.py 397 -> 246 + _claims 241
_escape.py 300 -> 230 + _escape_types 82
_claims holds the claim document and everything that decides whether one is
still owned: taking the advisory lock IS the liveness proof, so reading a
claim and reaping a dead one are the same operation and belong together.
_arbiter keeps the policy — who outranks whom, and whether this app may arm.
An AST pass over every module in the package (names loaded vs. names defined
or imported) caught two things a green suite would not have:
- _try_lock and fcntl moved with the locking code, so the tests' patch
targets had to move too;
- OutputSource was left behind by the earlier _outputs split and only
resolved because of `from __future__ import annotations` — a latent
NameError the moment anything evaluated that annotation.
gatelock is now at 0 files over 250 lines: 389 passed, 100% branch coverage,
the same 58 public names, and palette_check still at 0 failing checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_output_types.py 62 — Output, OutputRect, OutputScan (data only)
_randr.py 157 — the optional python-xlib backend and its Protocols
_xrandr.py 112 — the `xrandr --query` subprocess fallback
_outputs.py 137 — the enumerator that picks between them
The two backends both needed the value types, which would have made the
dependency circular. The first attempt papered over that with function-local
imports; extracting the types into a leaf module removes the cycle instead, so
every import is top-level again — and, importantly, the tests' existing
`patch("gatelock._outputs.scan_xrandr")` targets keep working, which the
lazy-import version had silently broken.
_RR_CONNECTED moved with the RandR code and _XRANDR_TIMEOUT_S/_OUTPUT_LINE/
_GEOMETRY with the xrandr code, each carrying its comment. An earlier pass
tried to relocate those constants with a regex, which ate part of an unrelated
function body; that attempt was reverted and redone with asserted line
boundaries plus an AST check for unresolved names in both halves.
389 passed at 100% branch coverage; gatelock.__all__ still the same 58 names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_recovery.py 321 -> 242 + _recovery_types 47 + _grabwatch 84
_surfaces.py 380 -> 250 + _placement 136 + _surface_types 54 + _textmirror 70
Two guards had to be repaired first, because both would have gone quiet
rather than red:
1. palette-drift CI was ALREADY BROKEN by the earlier _window.py split.
palette_check.py reads LockConfig's colour defaults from a hardcoded
gatelock/_window.py, and LockConfig had moved to _config.py — so it parsed
0 tk tokens and reported "10 failing check(s)" where HEAD reports 0. Fixed
the path, and made parse_tk raise instead of returning {} when LockConfig
is not found: the empty dict is what let every tk comparison pass
vacuously while the drift guard was off for the whole stack.
2. test_recovery_invariants' symbols() read one hardcoded filename, so
splitting _recovery.py would have narrowed the "no weakening call"
invariant — the one that exists because of the 2026-07-25 unlock — to
whichever half kept the name, with the suite still green. It now globs the
whole _recovery*/_surfaces* family and fails if the glob matches nothing.
Verified it discriminates: a throwaway _recovery_probe.py calling
restore_vt_switching made the test fail, and removing it restored green.
GrabWatch is a small object rather than a free function because holds_grab
and its once-per-cause warning share state; _placement holds the geometry
questions and the re-assertion pass, which can only ever add visibility.
_surfaces keeps an explicit __all__: the autofixer pruned the mirror_text_widgets
re-export once and `import gatelock` broke, so the intent is now stated.
389 passed at 100% branch coverage; gatelock.__all__ is the same 58 names as
HEAD; palette_check back to 0 failing checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
aur-publish/publish.sh 439 -> 148 + lib/aur_registration 107
+ lib/aur_verify 122 + lib/aur_publish_one 107
guard-lib/lib/file_guard.sh 350 -> 140 + file_guard_unlock 214
file_length/config.py 252 -> 45 + _tables 202 + exemptions 77
The libs are sourced, never executed: they read the readonly config the entry
script declares, and a `readonly` re-run inside a twice-sourced lib aborts
under `set -e`. Verified by function parity rather than by eye — publish.sh
resolves the same 17 functions as before the split, guardctl the same 15 —
plus `shellcheck -x` clean on every file and a real `--help` / bad-option run.
file_length is the gate's own code, so a broken import there turns the gate
into a no-op. Split three ways to avoid an import cycle without a `# noqa`:
_tables holds the data, exemptions the rules that read it, config re-exports
both so `from file_length.config import is_generated` still resolves. The
first attempt was wrong — EXCLUDED_DIRS was left behind and every invocation
raised — caught because the gate was re-run in both modes rather than
assumed. Now verified: exit 0 on a clean file, exit 1 on an over-cap one,
--explain intact, and the same 61 exemption decisions as HEAD.
Also deletes baseline-2026-08-14.json and SURVEY-2026-08-14.txt: the task
forbids a baseline file, and nothing in the tree read either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
firebase_auth_rest_test.dart 525 -> 153 + _google 158 + _tokens 206 + _uid 141
sync_revisions_test.dart 451 -> 223 + _peers 228 + _backends 158
+ _order 165 + _persistence 215
sync_settings_screen_test 457 -> 207 + _backup 143 + _google 145
mirror_store_test.dart 452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
firebase_client_test.dart 374 -> 198 + _writes 227
firebase_config_test.dart 360 -> 138 + _client 148 + _client_google 164
device_auth_test.dart 307 -> 128 + _poll 201
github_client_test.dart 291 -> 143 + _writes 201
sync_test.dart 269 -> 160 + _skips 175
Two traps this hit, both invisible to a line count:
- _FakePersistence is declared *after* main(), so a naive cut at the last
brace dropped it; sync_revisions_persistence_test.dart failed to load
until it was carried across. Audited every other split file for trailing
top-level declarations — this was the only one.
- Copying the preamble into each sibling left helpers the sibling never
uses. `dart analyze` flagged 12; all removed rather than left as dead
code, and the analyzer is now clean.
crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
test_firebase_auth.py 439 -> 170 + _google 159 + _tokens 159 + _session 175
test_sync_revisions.py 406 -> 163 + _peers 162 + _backends 216
test_firebase.py 369 -> 212 + _writes 221
test_mirror.py 364 -> 178 + _fallback 229
test_config.py 355 -> 139 + _credentials 112 + _client 233
test_github.py 301 -> 127 + _writes 219
test_sync.py 271 -> 195 + _skips 130
Cut at concern boundaries, not line counts: test_sync_skips.py holds the five
cases where a peer's pushed file is missing, corrupt, wrong-shaped or our own
— one story about what sync_log declines to merge — rather than an arbitrary
half of the original class.
259 passed at 100% branch coverage, verified against a clean HEAD worktree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Each file split at a class or concern boundary, never mechanically:
test_arbiter.py 432 -> 222 + _lifecycle 183 + _release 201
test_outputs.py 418 -> 187 + _randr 204 + _enumerator 126
test_scrollable.py 374 -> 231 + _into_view 197
test_escape.py 343 -> 175 + _budget 216
test_window_surfaces 341 -> 110 + _lifecycle 104 + _preempt 182
test_surfaces.py 282 -> 167 + _focus 154
test_window.py 269 -> 143 + _grab 139
Test counts verified against a clean HEAD worktree, per file group rather
than in aggregate: arbiter 49/49, outputs+escape 84/84, window+surfaces
72/72, scrollable 18/18. The whole suite is 389 passed at 100% branch
coverage, identical to before.
The one non-mechanical part: _reply/_randr_display are module-level helpers
defined *between* classes, so they did not travel with the header and had to
be moved to the RandR file by hand — six tests failed until they did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
The file's own docstring described three layers; they are now three files:
test_recovery_invariants.py (52) — the static AST check that _recovery.py
contains no weakening call at all
test_recovery_reassertion.py (218) — what a tick re-asserts: grab, focus, VT
test_recovery.py (208) — the 2026-07-25 monotonic-sequence walk,
tick mechanics, and scheduling
Splitting the classes made pylint's R0801 fire: both halves built a
RecoveryLoop with the same RecoveryCollaborators block. Rather than suppress
it (banned) the shared construction became conftest.build_loop(root, config),
which the `loop` fixture now uses too — so the tests that need a non-default
config ask for one instead of restating the wiring. The output scans both
halves drive moved to conftest alongside it.
389 passed, 100% branch coverage — same tests, same count, three files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
A refactor without a gate silently regrows, so this is the part that makes the
cap stick. Until now the repo had no root .pre-commit-config.yaml at all --
`git commit` ran zero hooks, and the five subproject configs only covered their
own directories.
Adds a root config wiring scripts/check_file_length.sh as a local hook, and
carries the same hook in each of the five subproject configs so committing
with one of those is gated too. Staged files only, so an unrelated commit
never breaks on a violation it did not introduce; exit 1, not a warning; no
baseline and no allowlist, since those are suppressions.
Verified in a throwaway worktree, both directions:
- a staged 261-line file fails the commit (exit 1, HEAD unchanged, no
commit object created)
- a staged 200-line file commits normally (exit 0)
The 44 files still over the cap cannot block this: the hook only ever sees
what a commit touches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Cut along concerns rather than line counts, so each file answers one question:
_scrollfit.py (180) — does the content fit, and what layout follows?
_scrollkeys.py (176) — key/wheel bindings and the six scroll commands
_userintent.py (106) — was this focus move the user's doing, or the app's?
_scrollable.py (243) — the widget wiring and the public surface
_scrollfit takes an explicit FitTarget bundle rather than the surface, matching
the ArmingCollaborators pattern; UserIntentTracker owns the two fields that
answer "did the user just act?", which is the guard that keeps the viewport
from scrolling itself under someone who touched nothing.
The 45-line module docstring — the three Tk defaults this viewport exists to
prevent, and the failure behind each — moved to docs/scrollable-viewport.md
rather than being trimmed. It is load-bearing rationale, so it is referenced,
not deleted.
389 passed, 100% branch coverage — unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_window.py was the repo's largest file and could not be read in one piece.
Split along the seams the file already marked out, keeping the public API
and every existing import path intact:
_config.py (215) — LockConfig, the design tokens and preset resolution
_arming.py (178) — the way up: VT-disable, backdrop, surfaces, input grab
_preempt.py (118) — naming a blocked grab's holder, standing a weaker one down
_hooks.py (51) — the LockWindowHooks Protocol, pure interface
_window.py (243) — construction, focus, and teardown
_arming takes an explicit ArmingCollaborators bundle rather than the window
itself, mirroring the existing RecoveryCollaborators; setup() returns whether
VT was disabled instead of mutating the window. That leaves no reach-back into
private members (ruff SLF001 clean) and makes the arming sequence testable
without a window.
LockConfig is re-exported from _window, so `from gatelock._window import
LockConfig` — which five sibling modules and the tests already do — still
resolves. Tests that patched moved symbols were retargeted to their new home;
the dead _maybe_preempt delegate, whose only callers were tests, is gone.
389 passed, 100% branch coverage — identical to before the split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
Upstream clone at ~/Mapperatorinator is a third-party sibling (MIT,
github.com/OliBomby/Mapperatorinator), driven by subprocess from
~/osu-automapper and never vendored or modified.
Verified both directions: is_vendored() returns True for a 2538-line upstream
file (which would otherwise fail the cap) and False for ~/osu-automapper's own
sources, which stay gated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iJHHN18dud8dXpjd7ff3R
gatelock is not the docstring problem its siblings are -- 312 of its 394
findings are pytest fixture idiom (redefined-outer-name, protected-access),
so the prompt makes that the explicit fork to settle with the user first,
citing screen-locker's tests hook as the existing precedent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV