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
- coverage 7.16.1 -> 7.16.2 (dependency freshness).
- workout_screen.dart (253) -> _allSetsCompleted/_isPaused move into
workout_screen_breaks.dart next to _stateOf.
- storage_service_modes_test.dart (289) -> setExerciseSettings and
per-mode progression groups move to storage_service_settings_test.dart.
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Warmup is always 6 reps (kWarmupReps): even, one constant.
- Per exercise: rest after success / fail / warmup (0:30-10:00, 15 s
steps; defaults 3:00/5:00/3:00). Schema v6 NOT NULL DEFAULT columns,
synced in the Firebase progression record (lenient for older records).
The notification carries the fail rest so -1 rep re-cuts to it.
- Settings: PER EXERCISE list replaces PROGRESSION THRESHOLDS; each row
opens the workout tile's sheet and pushes progression when it closes.
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Per exercise: weight / reps / double progression (n=12, m=6), one pure
rule shared by Finish and every preview; streaks and next step shown on
the tile. Warmup is the first circle of the set row and can be turned off.
- Pause an exercise for n days (default 14): shown as paused, recorded as
FAILED every workout so it steps down; skipped by the notification.
- App bar removed: Reset/Finish live in the rest strip, no rest labels.
- Schema v5 (progression_mode, reps_high, reps_low, has_warmup, paused_until),
synced through the Firebase progression record; NULL warmup = plan default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
`flutter test` runs on a Linux host, so credentialStore()'s Platform.isLinux
branch wrote $HOME/.config/screen_locker/firebase_auth.json -- the refresh
token the screen-locker daemon signs in with. firebase_backend_google_test's
"stores the account under the email Firebase reports" case overwrote it with
the fixture token 'refresh' on every local run; the daemon only survived by
self-healing from a cached token.
Dart:
- lib/services/desktop_config_dir.dart: one resolver, desktopConfigRoot(),
for every desktop path (the credential file's read/write/delete and the
~/.config/crdt-sync account read). @visibleForTesting override; under
FLUTTER_TEST it throws StateError if the root is, or is inside, the real
$HOME/.config -- so a test that bypasses the override fails loudly instead
of writing. HOME unset now yields null instead of a cwd-relative .config.
- The crdt-sync read resolves the root outside its catch-all, so the guard
cannot be swallowed as "not connected".
- test/flutter_test_config.dart gives every test its own temp config root.
- The Google sign-in test now asserts the token landed in that temp root.
- path_provider is unbound under flutter test (MissingPluginException), so
the getApplicationSupportDirectory callers cannot reach live dirs.
Python (tests only): isolate_sync_token now patches every import-time
binding of SYNC_TOKEN_FILE (_constants, _manual_push, _sync_status were
missed), and test_home_isolation scans all runtime modules for any Path
still bound inside the real ~/.config/screen_locker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFwA9nymhUdTrVF65kigTV
Tests (~214 s, gated by flutter-coverage):
- Flutter suite runs as 8 parallel buckets of whole test files
(check_flutter_coverage.sh --shard I/N); flutter-coverage merges their
lcov files (--merged N) and applies the unchanged 100% bar and
every-lib-file-present guard. `flutter test --total-shards` splits test
cases inside each file, so every shard would still compile all 77 files.
Proved locally: the merged lcov matches an unsharded run line for line
(3312/3312), and a missing shard fails. flutter-action caches the SDK.
- pytest: uv install, -n auto -q -p no:sugar (local: 25.9 s -> 10.6 s,
100% coverage both ways).
- lock-popup-safety: uv, and no apt step -- xvfb-run and setup-python's
tkinter are already on the runner (screens-fit relies on both already).
pre-commit (~122 s): matrix of mypy / pylint / flutter-analyze / rest, each
installing only its own toolchain, with a per-group hook cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
The dependency-freshness gate (and the pre-commit workflow, which runs the
same hook) went red on these five upstream releases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
CI's dependency-freshness check runs live against the registries and
caught these after this session's local pre-commit run used a stale
snapshot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kwx6YYv9Mnw9EtRsJ6hbf
The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.
Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.
Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
05:00-08:30 (09:00 with the extended reward) locked nobody but did hold the
workout lock off on a clock that knew nothing about whether the user was
up, next to the morning-session rung that did. EarlyBirdMixin._is_early_bird_time
is now that rung's verdict: open before the alarm, during the session, and
until 11:00 once it completed in time -- exactly what wake-alarm signs. The
wake_alarm_skip rung banks the pending marker so the carrot's end still tries
a phone/RunnerUp workout before locking; early-bird-workout-check.timer moves
to 09:30:30 and 11:00:30, the two possible ends. The consumer's boot-retry
window starts at 05:00 with the refresher.
Also: the sick screen lists four past justifications, one line each -- six
full-length ones pushed it 7 px past 1366x768, which the screens-fit gate
only sees with a real history on disk. Takes @types/node 26.6.2, eslint 10.11.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
The wake_alarm_skip rung has been dead since the repos split: it read
../wake_alarm/wake_state.json (no longer a path) for a skip_workout field
nothing ever wrote, and answered "no skip" for a year without an error.
It now reads ~/.local/state/wake_alarm/morning_session.json, written
HMAC-signed by wake-alarm every two minutes through the morning, and the
whole decision is: signature ok, dated today, now < exempt_until. A
completed-in-time morning defers the lock to 11:00; a live one until
min(start + 90 min, 09:30); nothing else changes the ladder. The enforce
path waits up to 30 s for a freshly booted PC's refresher; status paths
never wait. Takes Flutter 3.47.5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
The pre-push gate refused the previous commit over these two; both are
patch/minor moves and the web suite (52 tests, 100% coverage) is
unchanged on them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults that together locked kuhy out on 2026-09-18 with a workout
on disk and "Manual-workout budget exhausted" on the screen.
1. The football logged at 00:02 on 2026-09-13 was rekeyed to its local
day (keeping workout_id manual:2026-09-12T14:30), published, and pulled
straight back by the 09:40 tick as a new record: _already_ingested only
matched sync_record_id (which a locally logged entry never carries) and
the chokepoint re-derived the id from the corrected date, so the same
workout landed twice. Ingest now files a synced manual under the wire
id it arrived with and recognises an entry whose own workout_id is that
record.
2. count_in_window counted raw manual_workout entries, so the twin took a
second budget slot for a week (2/2) while the weekly count, which goes
through credit_key, saw one. The budget now shares credit_key -- the
rule the docstring says every counter must share -- and distinct
workouts on one day still cost a slot each.
3. _check_today_state_exits returned False (full lock) from the expired
early-bird branch before ever consulting has_logged_today, so a manual
walk entered on the lock screen at 09:32 held for exactly one tick: the
09:35 run locked again ("also=workout_logged_today"), and kept doing so
every 5 minutes while the day's marker lasted, which is how a sick day
got spent over a logged workout. The explain chain already treated the
expired marker as non-terminal; the live ladder now falls through the
same way.
_manual_sync.py went over the 250-line cap and is split: draft
reconstruction lives in _manual_sync_draft.py, re-exported from the
original path so patch targets are unchanged.
Replayed on the real log: count_day_credits("2026-09-13") == 1 while the
old count_in_window said 2; after the fix the status API reports
used_7d=1, exhausted=False. test_manual_rekey_dedup.py fails 3/4 on HEAD.
1334 tests, 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The between-set break never finished once the phone's screen was off. Three
independent causes (worked through on-device on 2026-09-12; left
uncommitted awaiting the go-ahead, committed here as-is plus one pin bump):
1. The countdown was not wall-clock: `_breakRemaining--` on a
Timer.periodic, which Android throttles and Doze suspends in a
backgrounded isolate, so the counter stalled and _onBreakFinished()
never fired. Now a BreakClock deadline on the wall clock.
2. An expired break was silently dropped on restore (`if (remaining > 0)`
only) — now cued and logged.
3. No notification infrastructure at all: no package, no
POST_NOTIFICATIONS / FOREGROUND_SERVICE / WAKE_LOCK.
Now: flutter_foreground_task runs the countdown in a service isolate that
survives BACK and the screen lock; flutter_local_notifications shows an
ongoing "Workout A · break m:ss / Next: …" notification with −1 rep / Done
actions and a "Break over" alert on its own channel (raw break_end sound,
vibration). Button presses go through a persisted BreakIntent queue the
screen drains on resume, so a press with the app killed is not lost.
Finish and Reset stop the service; a startup reaper handles the crash case.
Verified on the phone with the screen off and locked: service alive and
ticking while Dozing, both notifications on the lock screen, channel
importance 5 with the break_end sound. Not verifiable from adb (receiver is
RECEIVER_NOT_EXPORTED): an actual action-button tap, and a true recents
swipe — see stronglift_replacement/TODO-workout-app-v2.md.
Also fixed en route: the break-end sound had never played in any widget
test (AssetSource needs path_provider, which nothing faked, and the failure
was swallowed by catchError); the queue's prune wrote back a stale list.
This commit additionally:
- takes the freshness gate's bump flutter_local_notifications 22.3.1;
- restores web/package.json to HEAD (the tree held a stale pre-bump copy);
- gives scripts/check_flutter_coverage.sh a FLUTTER_TEST_CONCURRENCY knob:
the default one-isolate-per-core run peaks over 4 GiB and is OOM-killed
under the resource cap. Default behaviour unchanged.
Gate: flutter analyze clean, 511 tests, 3187/3187 lines (100%), pre-commit
green. Not re-installed on the phone for the 22.3.1 pin: no device attached
at commit time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
manual:2026-09-13T14:30 -- the synced copy of the football that this
morning's day-key rekey let back in under a second id -- made every
workout-sync tick log "Manual-workout budget exhausted -- not ingested".
Tombstoning it once did not stick: sync_log pulls only the OTHER devices'
logs and _manual_push re-derives the PC's log from log.json each tick, so
the next push overwrote the tombstone ("tombstoned", then "LIVE" five
seconds later). Deletion is monotonic only for records still in the merge.
- New _sync_tombstones: {id: {deleted_hlc, reason, tombstoned_at}} in
screen_locker/sync_tombstones.json (gitignored with its siblings). The
HLC is minted once and stored so re-pushing does not churn the store.
The ledger path is resolved at call time, not as a default argument, so
the test suite's redirect actually applies -- verified by hashing the
real ledger before and after the run.
- _manual_push.records_from_workout_log merges the ledger over the live
derivation, so every push carries the tombstones.
- scripts/tombstone_sync_record.py <id> [--apply --reason ...]: dry run
reports live/tombstoned; --apply writes the ledger, runs one tick and
reports what the store holds afterwards. Rerunnable.
- tests/_isolated_state.py redirects SYNC_TOMBSTONES_FILE (both bindings).
Verified: --apply -> "tombstoned"; a second ordinary tick -> "already
tombstoned" with the same HLC; workout-sync.service now logs "Sync record
manual:2026-09-13T14:30 is tombstoned -- skipping it" and the budget line
for that id is gone. log.json untouched (the local entry stays; it shares
the original's credit per duplicate_credits.json).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
Pin only — 0.8.2 (wait_for_x_server waits as long as it takes) was
already the installed version the suite ran against; CI's
dependency-freshness gate flagged the 0.8.1 pin on 82f3462.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
The i3bar showed 21:00 on 2026-09-13 with a counted workout on disk. The
football was logged at 00:02 and credited against the previous day's
config (already at the 23 ceiling, so it bought nothing); the 09:15 base
reset then wrote 21/21 and only re-layered the weekly bank, never asking
what today's log had already earned. Live credits are read-add-write, so
every workout credited between local midnight and the first locker tick
of the day lost its hours to the reset that followed.
- _shutdown_base.reset_to_base_if_new_day takes the log and writes
min(23, base + hours today's entries earned) in one write, using the
same credit rule as the weekly count. Idempotent: still once per day.
- _workout_credit names FIRST_WORKOUT_BONUS_HOURS=2 /
EXTRA_WORKOUT_BONUS_HOURS=1 and derives earned_shutdown_bonus_hours()
from them; _shutdown and the +1h path use the constants instead of
literals so the replay and the live paths cannot drift.
- _weekly_check.credit_key: a synced copy whose sync_record_id names an
entry already filed on the same day shares that entry's slot. This
morning's rekey (967e3cd) let the sync pull the football back in as
manual:2026-09-13T14:30, which counted as a second credit (weekly 4/5
instead of 3/5) and would have made the replay ask for 24.
- annotate_duplicate_credits.py passes the day's id index so the ledger
records the manual duplicate the same way it records phone/PC pairs.
Verified through the real path: cleared last_reset_date, started
workout-locker.service -> "--restore 23 23 5", "Daily base reset:
Mon-Wed=23, Thu-Sun=23 (base 21/21 + 2h already earned today)",
shutdown_countdown.sh prints "23:00"; a second start changed nothing.
leetcode-guard grants no shutdown time anywhere (grepped screen-locker,
digital-wellbeing, i3 config) — the 23:00 comes from the workout alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
Pre-push ci-mirror was red at HEAD on exactly these four; the lock was
regenerated with the pinned Flutter 3.47.4 (which also moves
flutter_secure_storage_platform_interface to 2.1.0 transitively).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
A manual workout logged at 00:02 CEST on 2026-09-13 was filed under
2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at
that instant UTC was still yesterday. The 09:18 lock check looked for
2026-09-13, found nothing, and locked the PC — for a workout that was on
disk. The budget counter (a trailing window) counted it fine, which is
why the status showed 1/2 used while the locker still locked. Any log
between local midnight and 02:00 CEST hit this; it had happened before.
- New screen_locker/_day.py: today_str() is the one definition of "what
day is it" — the local calendar day the user sees on the clock, which
is also what the phone app, RunnerUp verification, the status window
and the freedays pool already used. All 14 UTC sites (log writer, lock
predicates, explain chain, early-bird, wake state, shutdown/sick state,
sick budget, manual CLI, phone verification) now go through it, writers
and readers together so no new midnight window opens between them.
- write_signed_entry dedups by workout_id across ALL days: a workout id is
globally unique, and an entry moved to its correct day must not let its
synced twin (still stamped with the old day) re-ingest as a duplicate.
- scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day
(the HMAC never covered the day key, so it still verifies).
- test_local_day_key.py freezes the clock at the exact failing instant.
Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly
3/5) against HEAD in a throwaway worktree vs this tree:
HEAD -> logged under 2026-09-12, LOCK
fixed -> logged under 2026-09-13, no lock (already_logged)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
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