Daily timer (+10 min after boot) running the retirement, installed
and removed with the other screen-locker units; README section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.
- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
only, proven with can_access_remote(), healed once from a sibling app's
session, else a warning naming what will not sync (push AND pull) plus a
recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
(the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
"firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
install_systemd.sh (the installer still said v0.9.0).
kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
The base hour, the workout/LeetCode/reading hours and the reading cut date
were duplicated here and in steam-backlog-enforcer. They now come from
earned_time (kuhyx/utils, earned-time-v0.1.0): the reset sums the registry,
the live pass iterates its flat earners (stamps unchanged:
<name>_bonus_date), and _leetcode_bonus/_reading_bonus are replaced by the
shared HMAC ledger reader. A gate registered later needs no code here.
The schedule still stores whole hours; _earned.to_hours raises on a
remainder rather than flooring it. Verified identical to the old code on
today's real ledgers and log (reset target, stamps, live passes, base hours).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aktgaq93yt51c6D3X5Fzsm
_morning_session.py is now a thin binding of screen-locker's path and retry
budget over gatelock.morning_session, shared with leetcode-guard and
book-guard; the verdict tests live there once. Same behaviour, same string.
Also bumps mypy 2.4.0 / pylint 4.1.1 / ruff 0.16.10 (newest stable gate) and
the stale gatelock pins in install_systemd.sh and the pylint hook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
The grab axes are grab=GrabPolicy(kind=..., disable_vt=...,
preempt_weaker_holder=...) and every colour read goes through
config.palette; nothing about the lock's behaviour changed. Also carries
the pylint hook on 4.0.8 with the source-only scope config; the gate
stays at 8.0 until the remaining findings are cleared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
install_systemd.sh installed no dependencies at all, so on a fresh machine it
died three times over: tkinter dlopens libtk8.6.so, which needs the `tk`
package (the tkinter module itself ships inside `python`, so the package is
`tk`, not `python-tk`), and gatelock and crdt-sync are pinned git dependencies
in pyproject.toml that nothing ever installed. The post-install workout check
crashed with ModuleNotFoundError and the installer exited 1 -- which is how
install_core_system.sh reported "Workout screen locker" as failed on any
machine that had not been set up by hand first.
The last line was worse than that. It runs the real enforcer with
--production, which builds a Tk UI and grabs the screen until a workout is
logged. Driven non-interactively -- install_core_system.sh, CI, `vm run` --
it never returns, so the installer hung forever on its final statement rather
than failing. It now only takes the blocking path when a terminal is present
(or SCREEN_LOCKER_ENFORCE_AFTER_INSTALL=1), and otherwise prints --status and
notes that the systemd units it just installed carry the enforcement.
Verified in a pristine vmbox guest: install_systemd.sh exits 0 with tk,
gatelock and crdt_sync installed on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
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
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
A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.
- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
ffc6c72 the phone pushes under a per-install uuid, so that directory has
been frozen since 2026-07-17 and the PC kept returning a stale session
from 07-27 as if it were current. Both readers now walk EVERY device
directory; reinstalls keep minting new ones, so no single path can be
right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
field the phone never writes. That filter is why the one reader that did
walk every directory discarded real sessions, and it also kept PC-origin
runnerup/phone_verified records from being handed to a caller expecting
a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
`except GitHubSyncError` never caught it and a DNS failure took the whole
locker down (status=1/FAILURE, then a restart loop). Catch the common
parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
while syncing fine.
Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.
workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.
Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.
Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
- 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