The counting fix made the numbers right; the history still read as though
2026-08-24 held two gym sessions. This annotates the pairs instead of
deleting one of each, for two independent reasons.
Deleting is self-undoing: every re-ingest path dedups against log.json's
own contents rather than a remote ledger, so _session_sync re-pulls a
removed pc_workout_verified from the CRDT store on the next 15-minute tick
and re-applies its shutdown credit on the way in. And mutating signed
history is against the position revoke_sick_day.py already states -- the
correction belongs beside the record, not on top of it.
Scanning the real log found six shared credits, not the three spotted by
hand: 2026-07-17, 07-27, 08-10, 08-21, 08-24, 08-28. This has been
happening since July.
check_ledger_agrees refuses to write a ledger whose arithmetic disagrees
with count_day_credits, so the ledger cannot come to describe a rule the
locker does not use. The file is gitignored with the other state JSONs --
it names workout dates.
The guard's test needs a fixture restoring sys.exit. conftest's autouse
_block_real_tk_and_exit patches "screen_locker.screen_lock.sys.exit", and
that attribute path resolves to the global sys module, so sys.exit is
stubbed process-wide for every test in the suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
`requires-python` and the CI pin already said 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 exactly these three knobs.
What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, 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).
The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.
ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
Python side: gatelock v0.7.1 -> v0.7.2 and crdt-sync v0.9.0 -> v0.9.1 (both
re-cut so consumers stop resolving pre-upgrade constraints), plus every `>=`
floor turned into an exact pin -- five majors among them (mypy 1 -> 2.3.1,
pylint 3 -> 4.0.7, pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, ruff 0.8 ->
0.16.5) and mcp pinned at 2.1.1. `scripts/ci_mirror.sh` builds a clean venv
from requirements.txt and runs the whole CI set: 1146 tests, 100% branch
coverage, no lint or type finding.
workout_app: sync_settings_ui v0.1.0 -> v0.4.1 and ten exact pins, with the
flutter_secure_storage 11 chain (compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1).
411 tests green, analyzer clean, release APK builds.
CI's Flutter pin was 3.44.6 while `.fvmrc` says 3.47.2 -- the drift that made
kuhylog red on a commit that is clean locally. Both workflows now say 3.47.2,
and the gate reads that pin from now on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.
TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
The three helper scripts were only ever dry-run, so the no-silent-failures hook
never saw them until they were committed. Six handlers reported the reason to
stdout via _report() and nothing else — invisible to the log, and easy to miss
when the script runs as one step of finish_2026_08_24.sh amid a wall of other
output. These mutate benefit and sick-day state, so a quiet failure there reads
as success.
Log at error/warning alongside the stdout line, saying what was not done:
the bonus was not banked, the revocation was not persisted, the credit gate
cannot tell and so fails closed.
Verified by feeding the gate a corrupt log: it now logs the reason with a
traceback and exits 1, instead of printing one line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still
locked the screen. The trigger was auth: screen_locker was the only app with no
cached firebase_auth.json, so it fell back to a stale shared password and got
HTTP 400 every run. The defect was what it did next — an unreadable primary
source was reported as "no workout" rather than "could not check", which is the
same conflation that took 2026-06-12.
Recover the session by borrowing a live refresh token from a sibling app, track
degraded sources as state instead of a log line, and say so on the lock screen:
name the backend, the error, and how stale the data is, so a sync fault reads
as a sync fault.
Also fix two bugs found while finishing the recovery:
restore_and_bonus.py banked `existing + bonus` with no idempotence guard while
finish_2026_08_24.sh advertised "safe to re-run" — a second run silently
granted the compensation hours again. Guard on a durable
`shutdown_bonus_granted_for` list; the bonus map alone cannot answer the
question, since a week's total mixes earned streak hours with corrections.
_extra_benefits._save_state rebuilt state from four hardcoded keys, so the next
weekly rollover would have dropped that guard and re-armed the double-grant.
Merge onto loaded state instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
Masking the units is not enough on its own. install.sh and install_systemd.sh
both COPY unit files into ~/.config/systemd/user, which silently overwrites
systemd's /dev/null mask symlinks and re-arms enforcement -- so a mask alone
loses to anyone running the obvious install command by reflex.
The marker file is the second step: while it exists, no path may install units
or enable timers, and clearing it is an explicit --rearm. --status stays usable
while disarmed but says so loudly, because "armed: no" on its own reads like a
bug rather than a deliberate state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
Both previous escapes were unreachable, for the same underlying reason stated
two different ways. A Tk key binding cannot fire, because once the workout app
takes its seat grab X delivers every keystroke to that app alone. And "run
touch from another terminal" cannot be done, because the app covers the screen
with an override-redirect fullscreen window -- there is no other terminal to
reach. An escape has to be handled INSIDE the grab, by the app itself, or it
is not an escape.
So --demo-escape arms a key handler in the runner: Ctrl+Shift+Q ungrabs the
seat and closes the window. The grab, the override-redirect window and the
handoff are all unchanged, so the demo exercises the real locking mechanism
rather than a softened copy of it, and the hatch is the one deliberate way
out.
The handler ungrabs before closing. Destroying a window that still holds the
seat can leave the pointer and keyboard grabbed with nothing left to release
them, which needs exactly the reboot this hatch exists to prevent.
Not Escape, which the Dart side already binds for in-app navigation; shadowing
it would change the behaviour under test.
Verified in both directions against the real debug bundle, each under its own
Xvfb display: with the flag, the app exits on Ctrl+Shift+Q while holding the
grab and the lock takes the screen back; without it, the identical keystroke
does nothing and the app keeps the screen. A test asserts the production path
never passes the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
The demo promised three escapes and delivered none: the only way out was a
hard reboot. Two independent causes, and only one of them was demo-only.
launch_workout_app blocks until the app exits. Called straight from a Tk
button that froze the event loop, so the Escape binding and the close button
could not fire -- measured: zero handlers ran during the block, and the events
were never delivered, not merely delivered late. WorkoutSession drives the
same phases through `after` instead, on the main thread, so grab_release and
grab_input stay where Tk requires them and the loop keeps pumping (199 ticks
during a run that previously managed none).
The second cause survives a healthy event loop. The Flutter app takes its own
gdk_seat_grab with GDK_SEAT_CAPABILITY_ALL and never releases it, so while it
is up X delivers the keyboard to it and no binding of ours can fire. Correct
for production, fatal for a demo, and no flag turns it off -- so the demo's
escape is a file the app cannot intercept: touch ~/.cache/stop-workout-demo.
The previous commit's rationale for the blocking was wrong and is corrected
here rather than left to mislead. It claimed pumping Tk would let the recovery
watchdog steal the screen mid-workout; in fact release() stops the watchdog
first and stop() cancels its timers outright, so there were never any ticks to
race with. The blocking was incidental, which is why removing it costs the
production handoff nothing.
Verified with the real debug bundle under Xvfb: with the app up and holding
its seat grab, the event loop is alive, the escape file terminates it, and the
lock takes the grab back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
Trying the handoff by hand on the production lock means a global grab and VT
switching disabled -- if the Flutter app wedges, there is no way out. This
builds the same handoff against a demo-mode lock instead: grab="local", VT
switching enabled, an Escape binding and a close button, so three independent
escapes survive a hung app.
Verified end to end under Xvfb with the real debug bundle: the grab is
released, the app maps and prints WORKOUT_LOCK: ready, and closing it returns
the grab to the lock window with the status line updated.
Says in its own docstring what a green run here does NOT prove: under a local
grab gatelock's watchdog early-returns and never re-takes the grab, so the
1000ms steal-back that makes _recovery.stop() load-bearing in production is
not exercised by this script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
Root-caused the 2026-08-21 stuck-lock incident: a manual workout
unlocked correctly, but the mechanism behind "locked again seconds
later" was a stray workout-locker.service re-arm racing a second
process into building a competing lock window -- not a leetcode-guard
conflict as first suspected (its timer wasn't due until 13:00 that
day). Separately, diet_guard held the global grab for ~3 minutes
against a newly-arriving, higher-ranked screen_locker that morning,
which just retried and logged instead of making progress.
- _build_lock_window now calls gatelock.wait_for_turn before arming,
so a stronger incumbent is queued behind instead of raced.
- New screen_locker/_instance.py (flock-based, mirrors
leetcode_guard/diet_guard's own single-instance guards) stops a
second process from ever building a competing window in the first
place.
- Bumped to gatelock v0.7.1, which adds weaker-holder preemption
(SIGTERM, reusing the holder's own clean close() path) so a lower-
ranked incumbent can no longer block a stronger claim for minutes.
- Fixed a live crash in scripts/verify_screen_fits.py: it read the
real log.json, so today's exhausted manual-workout budget (itself
caused by the incident's duplicate entry) made the fit-check crash
on an unset _mw_error_label. Points at a path that can never exist
instead, so the measurement no longer depends on live data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
workout-locker.timer (added in e5db254) re-runs the full lock-decision
chain every 30 minutes, but is_relaxed_day() is a pure date check with
no memory of an earlier same-day dismissal. Clicking "Skip — No
Penalty" only closed the window without persisting anything, so the
next tick re-derived reason=relaxed_day and rebuilt the same popup.
Now the skip button writes a relaxed_day_skip marker to the log
(excluded from weekly counting and sync, same treatment as heat_skip),
and the startup chain checks for it before re-entering the relaxed-day
branch. Also renames workout_log.json to log.json throughout the repo,
tests, docs, and Dart comments, since the fix touches that file anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qH2hJ6mvCJd3ZXpAJSaF7
The screen locker stopped locking on 2026-08-04 and nobody could tell. Not a
logic bug: every predicate worked the whole time. systemd logged
timers.target: Found ordering cycle: early-bird-workout-check.timer/start
after graphical-session.target/start after basic.target/start after
timers.target/start - after early-bird-workout-check.timer
timers.target: Job early-bird-workout-check.timer/start deleted to break
ordering cycle starting with timers.target/start
and left the timer disabled. A timer that is WantedBy=timers.target must not be
ordered After= a target that itself comes after timers.target; systemd resolves
that loop by deleting the job, which is an absence, and absences page nobody.
That timer is the ONLY thing that closes the early-bird window. Each morning a
run inside the window banked early_bird_pending.json, logged "will re-check at
08:30", and exited 0. The re-check never came, so the marker stood all day,
every day, for thirteen days.
It stayed invisible because logging.basicConfig was called only in the
--sync-only branch, never for --production -- the mode systemd actually runs.
Journal proof: zero INFO lines from workout-locker.service since June. Every
early exit logged at INFO into a logger with no handler. Meanwhile the healthy
15-minute sync kept the system looking alive.
The 2026-08-15 Python split is NOT implicated: it was pure code motion, and
basicConfig already sat in that branch before it.
Fixes:
* Drop the After= that closes the cycle; Persistent=true so a machine asleep at
08:30 still re-checks after resume.
* Add workout-locker.timer (*:00,30). The service is WantedBy=
graphical-session.target, i.e. one decision per login, so the 08-16 -> 08-17
day and ISO-week rollover inside one long session was never re-evaluated.
* arm.sh: install units, enable timers, verify -- one command, because
re-arming was a four-command sequence that was easy to half-do, and
`systemctl enable` returns 0 while systemd deletes the timer job.
* check_enforcement_armed.py asks systemd what it actually scheduled (enabled
AND has a next trigger) and fails loudly otherwise. Wired as ExecStartPost on
workout-sync.service, so a disarmed locker marks that unit failed every 15
minutes instead of scrolling past. install_systemd.sh now fails on it too.
Logging (the reason this went unnoticed, so treated as a defect in its own
right):
* basicConfig for every mode, never one branch.
* Every enforcement-abandoning exit records a decision instead of an invisible
INFO: one greppable line -- DECISION lock=no reason=early_bird_window_active
weekly=0/5 -- at WARNING, since per CLAUDE.md an INFO explanation of a
non-action is a silent failure with extra steps. The enforced case is logged
too: "no line at all" was the outage's whole signature.
* A durable JSONL trail outside the repo (~/.local/share/screen_locker/), so
"when did this last actually enforce?" survives journal rotation. --status
and --sync-only record lock=n/a; "did not decide" must never look like
"decided not to lock".
* check_silent_failures.py now also catches sys.exit(0) that explains nothing.
Verified both directions: it flags 3/3 exits in the pre-fix _auto_upgrade.py
and passes the repo clean.
Sync noise: four records replayed as WARNINGs every cycle (~384/day), which is
how the actionable lines got tuned out. manual_workout:2026-08-15 is an empty
stub ({date, kind, type}) -- no workout to recover, and that day's credit was
already counted under a different sync_record_id, so no credit was ever lost.
The three "collided" records are dedup working. Both now report at info; a
WARNING from this logger again means something real. 4 -> 0 per cycle.
Verified on the live system: the periodic timer fired at 16:45 and the journal
shows `DECISION lock=no reason=scheduled_skip_day weekly=0/5` -- the line that
did not exist for thirteen days. Both timers report enabled and scheduled, and
no ordering cycle recurs. Today is a scheduled skip so the machine was not
locked mid-session; enforcement resumes tomorrow 08:30. 1007 tests, 100%
coverage, all hooks green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2b6ZjBcdrkSYnMujx4mWd
The gate this whole refactor was aimed at had two holes:
- `MAX_LINES` was 400, while `refresh_refactor_todo.py` reported against 250.
The hook and the TODO generator disagreed about what "over the cap" meant.
- `types_or` was [python, shell], so the hook had never inspected a single
Dart file. Every file in the refactor — workout_screen.dart at 633 lines
included — was exempt from the check it was being split to satisfy.
A violation also exited 1 while printing nothing, so a developer got a failed
commit with no indication of which file or by how much. It now names the file
and the overage on stderr.
Verified by planting a 260-line Dart file: the hook exits 1 and reports it.
Boundary checked at 250 (pass) / 251 (fail).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
A self-contained prompt for a fresh session to finish the last 10 files over
the 250-line cap, all Dart. It carries the parts that are expensive to
rediscover:
- scripts/split_dart_test.py, which works, plus the envelope it works in:
one pass from the pristine file, no post-main() declarations, no local
function declarations in the preamble. All four remaining test files sit
outside that envelope, and the fix (hoist shared fakes into a
_fixtures.dart sibling first, as its own commit) is written down.
- The three Dart constraints each of which cost a debugging cycle here:
setState is @protected so it cannot be called from an extension; extension
methods dispatch statically so a method a test fake overrides must stay in
the class (this hung four tests on pumpAndSettle rather than failing an
assertion); static members need qualifying inside an extension.
- 402 as the test-count invariant, and the note that dart analyze is a no-op
on test/ so the count is the only signal there.
- That the local gatelock (0.5.0) is ahead of the pin (0.4.0), so the popup
gate's failure here is environment drift and must NOT be "fixed" in code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
storage_service.dart the class: open, settings, active session
storage_service_schema.dart create / migrate / seed
storage_service_exercises.dart read+write a single exercise's row
storage_service_sessions.dart apply progression, record sessions
storage_service_backup.dart backup, restore, sync markers
Dart cannot continue a class body across files, so unlike history_screen these
are `extension`s in `part` files. Library privacy still applies inside a part,
so they reach `_db` and `_getSetting` unchanged and every caller still writes
`StorageService.instance.foo()` -- no public API change, nothing for
public_member_api_docs to demand.
Doing this exposed a bug in scripts/check_silent_failures_dart.py: its bare
`on Foo {` pattern (the catch clause form) also matched
`extension StorageServiceBackup on StorageService {`, so it reported the
declarations as swallowed exceptions. It now requires the try block's closing
`}` first. Verified both directions on a fixture: a real silent
`} on FormatException {` is still reported, the extension is not.
402 Dart tests still pass; flutter coverage still 100%; dart analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The RecoveryLoop call in HostileLock matches gatelock 0.4.0, which is what
requirements.txt pins and what the pylint hook installs. gatelock 0.5.0
regroups the five arguments into a RecoveryCollaborators object, so running
this gate against a locally-installed 0.5.0 fails with
RecoveryLoop.__init__() takes 3 positional arguments but 6 were given
That is environment drift, not a defect in the check -- against the pinned
version the gate runs and passes both halves. Comment says so, and says how to
resync, so the next reader does not "fix" the code to match whatever happens to
be installed. (I nearly did; against the pin that edit would have broken CI.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
verify_lock_popup_safety.py (318) -> _xvfb_reexec.py shared re-exec
_popup_form_check.py HostileLock, ticks,
the real-form check
verify_screen_fits.py (266) -> uses _xvfb_reexec too
conftest.py (329) -> _tk_mocks.py the tk mock + the
module patch list
_opt_in_fixtures.py requestable fixtures
three test modules -> _part4/_part5/_part15 siblings
conftest keeps every autouse fixture. pytest applies autouse fixtures from
conftest only, so one moved to a sibling would silently stop running and take
its safety guard with it -- verified by asserting the fixtures are still
listed, not just that the suite is green. The five near-identical _isolate_*
fixtures collapse into one table-driven _isolate_state_files, which also picks
up the _shutdown_sick_state binding an earlier split created.
scripts/ becomes a real package so the shared helpers are importable. Both
gates now run as `python3 -m scripts.<name>`, including CI -- the path form put
scripts/ on sys.path instead of the repo root, and the alternative was a
try/except ImportError that this repo's no-silent-failures hook rightly
rejects. Each gate was run in its exact invocation form.
Note: verify_lock_popup_safety still fails on
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`. That
is pre-existing -- confirmed by running it at HEAD in a clean worktree before
touching it -- a gatelock API drift, not this refactor. Left as found.
978 tests at 100% branch coverage. Gate: 41 -> 22 files, all remaining ones
Dart plus DESIGN_AUDIT_TODO.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Same pattern as todo and diet_guard_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel
dependence), isFirebaseConfigured() reading the stored session rather
than the account marker alone, and the settings screen offering
"Sign in with Google" ahead of the password form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
A dependency_override pointing crdt_sync at a local path is invaluable
while developing an unreleased library change, and silently catastrophic
if it survives the commit: local tests go green against code CI never
built. Enforce it can't ship instead of relying on someone remembering
to remove it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SH7XBkpEqQX4KghHdgx9T
Progression and the in-progress session now live at
screen-locker-sync/exercise_state/<name> and .../active_session — siblings of
devices/, not inside it. Both workout readers (_workout_sync.py:113 and
workout_sync_service.dart:307) list only screen-locker-sync/devices, so a
progression record is structurally invisible to them and cannot be miscounted
as a workout, which would silently grant unlock credit. Keyed on exercise name
because a reinstall mints a new per-install uuid.
Startup no longer calls requestStoragePermission(): that threw the user into a
system Settings page on every launch, for a permission that is now genuinely
optional. It only checks; the grant moved to an OFFLINE BACKUP section in
Settings, its sole caller.
The clobber guard keys on a persisted progression_synced_at marker, NOT
looksFreshlyInstalled(). The only production caller (_finishWorkout) writes a
workout_history row and last_workout_type BEFORE it pushes, so a freshness test
was already false by then and could never fire — a reinstall that connected
Firebase late (the uninstall wipes the keystore, so startup's pull finds no
account) would have overwritten every real remote record with factory defaults
on its first finished workout. Connecting in Settings now pulls immediately,
which closes that window. The same marker stops a pull reverting a deliberate
setExerciseWeight/resetExerciseToDefaults, which write no history at all.
Also adds a Dart silent-failure pre-commit hook. The Python one is
types:[python] and had never looked at a single Dart file — in the app where a
swallowed sync error costs the user a workout with nothing anywhere saying so.
It found 21 real silent swallows (keystore reads/writes, /sdcard mirror
failures, the HTTP bind, device-flow polls, and a .catchError((_) {})), all now
logging why.
Verified on the Pixel 6a: reinstall + deny storage + connect late restored
Bench 22.5→20.0, Row 22.5→25.0, Situp 30→31 reps with the remote copy
untouched; the RTDB device list stayed at exactly 3 entries and no progression
leaked into pull_synced_workout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
CI caught what this machine could not: the hook died with "No module named
'screen_locker'". The package is pip-installed here (editable, pointing at
this checkout), so running the script by path found it anyway; a fresh
checkout has no install, and `python3 scripts/x.py` puts only `scripts/` on
sys.path. `python3 -m scripts.verify_screen_fits` puts the repo root there
instead, which is the checkout in CI and here alike -- verified in a
throwaway worktree, where the module now resolves to the worktree's own
package rather than the installed one.
The xvfb re-exec runs the same way, with an explicit cwd so the child does
not depend on where the parent was invoked from.
pylint had the same blind spot for the same reason: it infers sys.path from
the files it is handed, so this script resolved its imports only when a
package file happened to be in the same batch -- passing or failing by what
else was staged. An init-hook puts the repo root on the path unconditionally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.
The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:
* Every font size and pad now goes through the design-system tokens rather
than a literal. A positive size means *points* to Tk, so "36" rendered
~37% larger than the 36px intended; that alone was most of the overflow,
on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
the three reflection boxes share rows instead of taking one each.
Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.
Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.
Tests: 896 pass. The focus/scroll helpers shipped in 3e77f4e had no coverage
at all; test_focus_and_scroll.py covers them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
Same defect diet-guard's pre-push gate hit today, latent here: git exports
GIT_DIR to its hooks, and the flutter tool shells out to git to identify its
own SDK. With GIT_DIR set it reads THIS repository instead of /opt/flutter,
so `flutter --version` reports our own HEAD as the framework revision and our
URL as the repository, pub resolves the SDK as "0.0.0-unknown", and every
dependency constraint becomes unsatisfiable. flutter also caches that answer,
so the damage outlives the hook run and follows the next caller into another
repo.
Both flutter gates now clear the git variables at the point of use. Verified
by running the analyze hook with GIT_DIR set, which failed before and passes
now.
Also fixes the SC2295 shellcheck finding the coverage script had been carrying
-- it only surfaced now because no change had touched a shell file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lock-popup-safety job failed on its first CI run for two reasons, and only
one of them was the obvious one.
The obvious one: the script imports screen_locker directly rather than through
pytest, which would have put the rootdir on sys.path for it. CI now installs
the package.
The one worth having found: CI installs gatelock from the pinned tag, which
predated `holds_grab`, so every hostile tick died on an AttributeError --
inside a Tk `after` callback, where exceptions are swallowed and logged rather
than raised. The harness sailed past, printed OK and exited 0 while doing no
grab re-assertion whatsoever. A verification script that passes when it is
broken is worse than not having it.
So it now fails closed twice over: an explicit check that the installed
gatelock has the predicate at all, with a message naming the fix, and a count
of ticks that actually completed -- incremented after the work, so a raising
tick does not count. Fewer completed ticks than asked for is reported as "the
harness itself is broken, so this result proves nothing", not as a pass.
The pin moves to gatelock-v0.2.1, which carries both of yesterday's fixes: the
grab predicate that no longer steals the grab off our own posted menus, and
the recovery loop that no longer stops forever when a tick raises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
Keeps CI green by closing the local↔CI gap that caused this session's
red runs: the installed pre-commit hook only checks staged files and the
dev env already has every dep, while CI runs `pre-commit run --all-files`
and installs a fresh env from requirements.txt.
- scripts/check_requirements_sync.py: static hook (pre-commit stage) that
AST-scans the package's third-party imports and fails if any isn't
declared in requirements.txt — the exact ModuleNotFoundError class that
reddened three repos. Fast, runs at commit time.
- scripts/ci_mirror.sh: pre-push gate that reproduces both CI workflows —
a requirements.txt-only venv (hash-gated, rebuilt only when
requirements.txt changes) running `pre-commit run --all-files` +
`pytest`. Replaces the old pytest-coverage pre-push hook (subsumed by
the clean-venv pytest). A red result blocks the push; `git push
--no-verify` overrides.
- .ci-mirror-venv/ gitignored; pre-push hook installed.
Verified: mirror runs green (747 passed, 100% cov); dep-sync check
catches a removed crdt-sync; hash-gate reuses the venv on repeat runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PMRRVVkeRyrSaaqueMZKG
Expose the screen-locker's compliance and lock-decision state as read-only
MCP tools (screen_locker._mcp) so Claude Code and its subagents can query
workout status without shelling out to the CLI or opening the Tk window.
- _mcp.py: FastMCP stdio server. Read-only tools get_status, get_summary,
explain_lock, get_flags. Import leaf over stdout-free status functions
(gather_status, format_summary_line, explain_lock_decision, predicates);
logs to stderr. NO write/action tools by design — workouts are logged only
from RunnerUp-verified TCX data, never from a caller's claim. No tool
exposes the sync token or any HMAC key.
- .mcp.json + scripts/setup_mcp.sh: registration via a dedicated
~/.venvs/screen-locker-mcp interpreter; mcp is an optional-dep.
- 100% coverage of the new module; verified end-to-end over stdio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v
- Add sqflite_common_ffi + very_good_analysis; tighten analysis_options
- Add BackupService for JSON export/import of exercise state
- Add full test coverage: models, screens, services, widgets
- Add scripts/check_flutter_coverage.sh to enforce 100% line coverage
- Add docstrings to ExerciseState fields and storage service
- Minor fixes across screens, widgets, and sync/HTTP services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuiPt6GPWkxpLbJFrnfy8U
- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py),
split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date
to keep cyclomatic complexity ≤10
- Generalise TCX lookup to any date in the ISO week (was today-only); all gap
days Mon→today auto-filled on every startup and 08:30 timer firing
- Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout
minimum, capped at midnight (hour=24)
- Add _shutdown_base.py: daily reset of shutdown config to a stored base so
the bonus doesn't silently accumulate across days
- Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits
for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks
- Add --status mode (_status.py): non-locking CLI view showing per-day
breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak,
skip credits, and early-bird status
- Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill
pushes weekly count above the minimum
- Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck,
codespell, max-file-length); 508 tests at 100% branch coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX
Add scripts/check_file_length.py and a max-file-length pre-commit hook that
fails any Python/shell file exceeding 400 lines. Extract UIWidgetsMixin and
UIFlowsRelaxedMixin from screen_lock.py and _ui_flows.py respectively, and
split 6 oversized test files into part2/part3/part4 siblings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>