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>