Add gatelock.corner_strip, the top-right always-on-top status strip for a
gate that is stood down but not done. Raise the morning-session wait from
30 s to 180 s: on 2026-11-07 a boot-time DNS race let the 30 s wait expire
and both lockers armed mid morning-session; wake-alarm's refresher now
retries 150 s in-process.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
A live claim was not "input is locked": it is published before the window
grabs, and leetcode-guard's study mode releases the grab while the claim
lives on. leisure-watch (contract C2) must pause shadow billing only while a
grab is really held.
- Claim.grab_held (always serialised; missing key from an old writer reads
as grab != "none"). Arbitration still uses the declared grab only.
- Arbiter.set_grab_held(held=) rewrites the claim and holder copies in place
through the owner's flocked fd (O_APPEND cleared, pwrite at 0, padded so
the file never shrinks) -- same inode, flock liveness untouched. Never raises.
- Set true only after grab_set/grab_set_global succeeded; RecoveryLoop
reports each tick's real answer (a stolen, un-retaken grab reads false).
LockWindow.report_grab_held(held=) is the hook for deliberate releases.
- gatelock.held_grabs(claims_dir=None): live claims with grab_held and a real
grab. Root callers pass /run/user/1000/gatelock/claims.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
screen-locker and leetcode-guard each carried a near-identical copy of the
reader for wake-alarm's signed morning_session.json; book-guard is about to
need it too. The policy stays in wake-alarm -- this only answers "signed,
dated today, now < exempt_until". Path and retry budget are arguments so
each consumer's test redirect keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
The 60 s deadline was the 2026-09-13 leetcode-guard miss with a smaller
number: a Persistent=true catch-up starts one second into a boot, before X
exists, and a bounded wait that expires exits the unit while systemd has
already stamped the slot as fired. No X server means the user cannot use
the machine either, so an unbounded wait is not a bypass. The wait now
re-states itself at WARNING every five minutes so a long wait never reads
as a healthy, silent unit. `timeout_s` stays as an opt-in for callers that
want a bound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxkZDHDmoVSxUT4w72xSiE
leetcode-guard's study mode has to stop the re-assertion loop and the
output-change watcher before it releases the grab, and could only reach
them as LockWindow._recovery / _detector -- attributes 0.8.0 folded into
the arming bundle, which broke that documented private dependency.
They are public read-only properties now, next to `surfaces`.
_window no longer re-exports the LockConfig family; it was the one
thing keeping it over the 250-line cap, and every importer now takes
those names from gatelock._config (or the package) directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
LockConfig was one 35-field record: the lock's behaviour, the grab
policy, nine colours, a type scale and a spacing scale, flat. It is
now the seven behavioural knobs plus four nested records --
GrabPolicy(kind, disable_vt, retry_ms, log_every, preempt_weaker_holder),
LockPalette, LockTypography and LockSpacing -- each overridden as a
unit. Breaking for every constructor that passed grab=/disable_vt=/
grab_retry_ms=/preempt_weaker_holder= or a colour, and for every read
of config.<colour>; hence the minor bump. screen-locker, leetcode-guard
and diet-guard follow in their own commits.
The two fail-open guards in the recovery loop carry per-line
suppressions: catching everything is the property they exist for, and
the check stays live for the next careless except. That was the last
finding, so the gate now says 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Same config move as crdt-sync (ignore-paths, init-hook, the three
implicit-booleaness disables the reference repos carry). The gate stays
at 8.0 for now: LockConfig's 35-field record and the two fail-open
guards in the recovery loop are still open.
Everything else pylint had left on source is gone. The optional
python-xlib dependency is loaded through one importlib helper instead
of two function-scope import statements, keeping the "not installed"
arm reachable for branch coverage; RecoveryLoop and LockWindow hold
their collaborator bundles rather than unpacking them into a second
set of attributes; the claim types are imported from _claims, where
they are defined, so _arbiter no longer needs an export list that
mirrored the package's; runtime imports sit above TYPE_CHECKING
blocks; the RandR Protocol stubs say which Xlib call they mirror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Both libraries had already moved `requires-python` to 3.14, but ruff, mypy and
pylint were still grading against 3.10 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.
What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), a dead `timezone` import left behind by it, and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one makes the source 3.14-only at the syntax level, which is what
`requires-python = ">=3.14"` already promised; kuhy chose it deliberately over
holding ruff a release back (2026-08-29).
These two are libraries the apps install, so the floor matters more here than
anywhere else in the fleet -- every consumer already pins >=3.14.
Per project: ruff, ruff-format, mypy --strict and pylint clean, crdt-sync 432
tests and gatelock 389 tests, both at 100% line and branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
**The gate could not see the shared Python libraries at all.** They are
consumed as PEP 508 direct references --
`crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync`
-- which parsed as an unpinned PyPI package that does not exist on PyPI. That
answer is undeterminable, not stale, so wake-alarm sat on gatelock-v0.4.1
against v0.7.1 and crdt-sync-v0.5.1 against v0.9.0 with a green gate. They are
git-tag dependencies now, judged the same way the Dart libraries are.
A ref that is a branch or a SHA still parses as before: there is no version
in it to compare.
The line is found textually when the key indexer cannot help. A TOML
dependency array holds bare strings with no `key =`, so these findings would
otherwise report line 0 and make the fix a search.
Both libraries also had their dependency upgrades land after their newest tag
(81d2493), so anything moving to "latest tag" would have taken the pre-upgrade
constraints. Cut 0.9.1 and 0.7.2; version bump only.
315 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
Sixteen `>=` floors become exact pins across the two Python packages, taking
five majors with them: mypy 1.13 -> 2.3.1, pylint 3.3 -> 4.0.7, pytest 8 -> 9,
pytest-cov 5 -> 7, bandit 1.7 -> 1.9.4, plus ruff 0.15 -> 0.16.5 and
requests/types-requests. A `>=` floor is satisfied by everything newer, so it
told a checker nothing about whether these repos were actually current.
Verified in a clean venv per repo, which is what both CI workflows do
(`pip install -r requirements.txt`): gatelock 389 tests at 100% coverage under
xvfb, crdt-sync 432 tests at 100%. No test change was needed for any of the
five majors.
**Open, and deliberately not in this commit:** `.pre-commit-config.yaml` pins
these same tools AGAIN via `rev:` (ruff v0.15.2, mypy v1.13.0, pylint v3.3.2),
independently of requirements.txt, and the gate does not read `rev:` pins at
all -- so the linters CI actually runs are still on the old majors. Bumping
them is a separate and much larger job: ruff 0.16 raises 71 findings here
(CPY001 alone accounts for many, since this repo selects ALL) and mypy 2.x
raises 60, mostly stricter implicit-re-export rules. One major per commit, and
that is several.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
Under i3 (not Xvfb), a managed tk.Tk() toplevel's geometry() request
was silently rewritten to the tiled pane size instead of the
requested WxH, so the content never actually overflowed and every
assertion built on "does this overflow" failed -- 11 tests across
test_fitcheck.py/test_scrollable.py. Fix: overrideredirect the
throwaway root, matching how every production lock window is already
built and for the identical reason (a WM must not be able to rewrite
geometry a fit/scroll check depends on).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
wait_for_turn only helps a weaker locker arriving after a stronger
one; it does nothing when the weaker one is already the incumbent.
On 2026-08-21 diet_guard (rank 100) held the grab for ~3 minutes
against a newly-arriving screen_locker (rank 200), which just
retried and logged instead of ever making progress.
Fix: when a grab is blocked by a holder that outranks-us-not, SIGTERM
it once. The holder's own signal handler already converts SIGTERM
into its normal close() teardown (VT restore, arbiter release,
app-specific on_close) -- this reuses that path rather than adding a
new one, and never signals upward (never disturbs an incumbent that
outranks us), so it cannot become a way to bypass a stronger lock.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
wait_for_turn/stronger_claims already fixed leetcode_guard's grab-race
with screen_locker; screen_locker and diet_guard never got the same
fix, so a stray re-arm race between them was still live (confirmed
2026-08-21: diet_guard held the global grab 07:04-09:07 while
screen_locker built a competing window and retried its own grab
forever instead of queueing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
Phase 2 adds four exports (make_button, heading, row, WidgetGroup) plus a
wheel-scroll fix in ScrollableSurface. Minor, not patch: the surface grew.
Purely additive -- nothing was removed, so consumers still on the 0.4.1 pin
keep working unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
LockWindow.grab_input() only ever focuses the entry once, on first grab
acquisition (_notify_focus_ready is one-shot). A surface torn down and
rebuilt later -- a monitor going dark and recovering, which flaky DDC/CI
power control does routinely -- gets a brand new widget that nothing
ever focuses. The grab stays held, so no other window can take focus
either: the entry renders correctly but is permanently keyboard-dead
until the process restarts.
RecoveryLoop.tick() now re-runs the same focus_surface(preferred_focus_
index()) call whenever a tick's surface delta reports a newly created
output, mirroring what LockWindow does on first acquisition. Bundled the
loop's five collaborators into a RecoveryCollaborators dataclass rather
than growing its constructor past ruff's five-argument limit.
Diagnosed against a week of wake-alarm's real journal history: the
dismiss-code box rendered normally every alarm morning but accepted no
keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips
near 07:00 and 07:55 each day.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
On 2026-08-03 the screen locker's lock screen scrolled to mid-content and
back with nobody touching the keyboard, on a 1366x768 panel. Measured under
Xvfb with the real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145.
Two causes, both here:
* ScrollableSurface followed every <FocusIn>. The apps focus a widget on
every repaint (and gatelock re-asserts focus from its recovery tick), and
Tk delivers <FocusIn> to the focused widget's whole ancestor chain when the
toplevel takes X focus -- so each repaint aimed a scroll at containers
taller than the screen. Now a focus move scrolls only when it followed a
real keypress, or a click on the widget that took focus, and only for a
leaf. Tab onto a clipped field still reveals it; nothing else moves.
* finalize()'s reset-to-top cancelled the centring, because centring moved
the canvas item down and the region's top edge with it. The region is now
pinned to the viewport whenever the content fits, so the view cannot move
at all, the scrollbar is taken down, and centring survives the repaint.
Content that does not fit is a layout defect, not a mode to live in, so
finalize() reports it at warning and gatelock.measure_fit() lets each app
gate on it before shipping (scripts/verify_screen_fits.py in all four).
Also: LockConfig's type/space scales now compact below 768px (_density),
which is where a screen has no room to be at design-system size and no
scrollbar to make up for it. 768 and up is verified to fit at full size, so
it is never compacted.
Tests: _scrollable had none; the regression is now stated as code
(programmatic focus must not move yview; Tab must). 323 pass, coverage
95.38% -> 99.44%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
gatelock v0.3.0. Every change here works around a Tk *default*, not an
application mistake -- which is why they belong in the shared backend rather
than being fixed four times.
On a lock surface these are not accessibility niceties. The lockers hold a
global input grab with VT switching disabled, so a control the keyboard cannot
reach, or a button clipped off the bottom of the screen, is a bricked machine.
LockConfig gains a type scale, a spacing scale and a focus-ring token:
- font() applies Tk's sign convention. Tk encodes the unit in the SIGN of the
font size -- positive is points, negative is pixels -- so passing the
design system's px values straight through rendered every string ~30%
oversized (measured: body at 16pt has a 26px linespace where 16px gives 20).
That alone pushed diet_guard's meal gate to 866px of content inside a 739px
pane on a 1366x768 screen.
- focus_kwargs() sets highlightcolor, the *focused* ring. Tk's default is a 1px
BLACK ring, invisible against the palette's #211D1B background. Note
highlightbackground is the UNfocused ring: setting only that one inverts the
affordance, which is what diet_guard was doing.
_keyboard.py collects three Tk defaults that each make a form unusable:
- tk.Text traps Tab (inserts a literal tab and refocuses itself) and no-ops
Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- tk.Button binds <space> on X11 but never <Return>, so the key a user reaches
for to submit does nothing.
- Escape needs binding at the toplevel to work wherever focus happens to sit.
_scrollable.py adds ScrollableSurface, a viewport that content can actually be
reached in:
- A place()-centred frame takes its *requested* size and is clipped
SYMMETRICALLY, so overflow removes the heading and the submit button at the
same time, with no scrollbar and no hint anything is missing.
- A bare tk.Canvas has no class key bindings, so ::tk::FocusOK rejects it as a
focus stop and the only way to scroll is dragging the thumb with a pointer.
- Canvas clipping does not unmap children, so scrolled-out fields stay in the
tab ring while Tk never scrolls to follow focus -- Tab lands on fields the
user can neither see nor reveal.
- A Canvas holds content in a canvas *item*, so it never propagates the
content's requested size; a toplevel told to size itself has nothing to grow
to. _request_content_size() fixes that, clamped to the display.
center_when_fits keeps short screens looking exactly as they did, so the
viewport can replace a centred frame without a visual change.
A scrollable dialog Toplevel is deliberately NOT the mechanism: on an
override-redirect lock surface a second toplevel steals the Tk grab, which the
recovery tick then kills -- the 2026-07-26 failure where a frozen sport
selector logged a walk as table tennis.
unified-design-system gains the two rule families this implements (pointer-free
operability, 1366x768 usable / 1024x600 graceful), a focus-ring token, and the
px/pt sign convention written down so it cannot recur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Releases the two fixes from the previous commit so the four consumers
(screen-locker, diet-guard, wake-alarm, testsAndMisc) can pin them: holds_grab
no longer steals the grab back off our own posted menus, and a raising tick
no longer permanently stops the recovery loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On 2026-07-25 the PC appeared frozen and needed an SSH rescue. Three defects
stacked:
1. DP-0 came up `connected` with a valid EDID and no mode/CRTC -- physically
dark, while X still reserved its 3840px slice of the root window.
2. diet-guard-gate and workout-locker each mapped an override-redirect window
spanning the WHOLE root (6400x1440+0+0), centring their controls on the
primary output -- the dark one. The escape UI was invisible.
3. They deadlocked on the X grab, and the loser logged "another app -- e.g. a
fullscreen game -- holds it". No game was running; the holder was the other
locker. That guess sent the diagnosis the wrong way for the whole outage.
The library had no concept of an individual output: setup() sized the window
from winfo_screenwidth(), the union bounding box of every output.
What changes:
* _outputs.py An output is LIVE only if connected AND has a mode/CRTC.
RandR (crtc==0 or mode==0) and an xrandr --query parser derive
that predicate independently; verified to agree exactly on the
real DP-0/HDMI-0 layout. Fails closed three distinct ways.
* _surfaces.py One window per live output at its own geometry, and the only
module allowed to create/move/destroy windows. Explicit
geometry replaces attributes(fullscreen=True), which an
override-redirect window is invisible to anyway.
* _detect.py RandR events + Tk <Configure> + xrandr polling, coalesced. The
RandR thread runs on a private X connection and may only call
queue.put -- Tk is not thread-safe.
* _recovery.py Re-asserts coverage, placement, grab and VT on a timer. Proven
monotonic: it may only ADD coverage or RE-ACQUIRE the grab. An
ast test asserts the file contains none of grab_release /
restore_vt_switching / close / destroy / quit / withdraw, while
disable_vt_switching and grab_set_global ARE present. Yanking
every cable therefore does not unlock anything.
* _arbiter.py N-app priority: wake_alarm > screen_locker > diet_guard.
Liveness is proved by flock-on-death -- no heartbeat, no PID
scan, no clock. A two-axis strength check stops rank ever
weakening the lock: an app stands down only for an incumbent
at least as strong on BOTH grab kind and VT disabling.
* _guards.py wait_for_x_server replaces wait_for_display and gates ONLY on
an X server existing. Output count must never gate arming:
zero live outputs means lock without showing, not decline to
lock. That inversion is what left the machine unlocked.
* _window.py Arms first, renders second. Names the real grab holder instead
of blaming a fullscreen game. Releases the arbiter on close so
the alarm -> workout handoff cannot strand the next app.
Two bugs found by running it, not reading it:
* acquire_holder() opened the holder file "w", which truncates at open() --
before the lock attempt. A losing contender erased the incumbent's claim, so
the app that most needed to know who held the screen found an empty file.
* tk.Toplevel over a MagicMock sends real tkinter into infinite mock recursion;
the suite hung instead of failing, and an unpatched setup() would have run
setxkbmap against the developer's own machine. conftest now has an autouse
hermetic fixture; the suite went from >120s to 0.7s.
235 tests, 100% branch coverage, ruff (select=ALL) and mypy --strict clean.
No suppressions added: Protocols replace Any for python-xlib, public attribute
access replaces ._data, and type-only Protocols live under TYPE_CHECKING.
Consumers still pin gatelock-v0.1.1 and are unmigrated; nothing ships until a
tag and a reinstall into system python.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors the palette rework already applied to diet-guard/dufs-cloud/
screen-locker. Adds `on_fill` (dark) for text drawn on a filled accent/
success/warning/danger surface -- near-white text under-contrasts on all
four (measured 2.2-3.6:1, below the 4.5:1 floor); dark text passes 5.8-9.7:1
on every one of them.
Bumps to 0.1.1 since this is a real release: the 3 consumer repos pin
gatelock to a git tag, not editable.
Extracted from wake_alarm, screen-locker, and diet_guard, which each
independently implemented fullscreen-lock, input-grab, VT-disable, and
HMAC-signed-state mechanics at different levels of maturity (the HMAC
module was already a hand-copied duplicate between two of them).
LockConfig exposes overrideredirect/grab/disable_vt as independent
axes so one LockWindow can reproduce all three projects' exact prior
behavior, plus screen-locker's confirmed upgrade to retry-forever grab.