Commit Graph

126 Commits

Author SHA1 Message Date
a826cfa166 workout-poke: instant phone credit over LAN (signed POST :8773) and Firebase stream
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:05:51 +02:00
aedfdbc7f5 Credit: one reward per workout slot, desktop notification, --help, runnerup-watch ignores re-pulls
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:05:08 +02:00
373a075606 Shutdown: walks as workout, rest days, grace floor, workout ledger (earned-time 0.4.0)
- A day's RunnerUp walks (_Walking.tcx) count once their moving time sums to 40 min
- Rest days: declared ahead, 2 per ISO week, HMAC-signed; pay the first workout unit
- Grace floor: shutdown >= first task + 60 min (capped); skipped on a sick day
- Workout ledger: one signed credit row per log credit slot and rest day
- Day-aware ladder amounts from earned_time 0.4.0; preview_shutdown dry-run script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:43:45 +02:00
f77e601893 runnerup-watch: credit a RunnerUp run the moment its TCX upload finishes; lock the workout-log write
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 20:36:38 +02:00
d1817675c4 earner-bonus: add the new modules, unit and tests missed by 7851d59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 20:44:01 +02:00
7851d592fd shutdown bonus: apply the moment a gate writes its ledger (earner-bonus.path)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 20:42:13 +02:00
ec7f4805f7 pin earned-time-v0.3.0 (registers the automation earner)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 15:20:10 +02:00
2dd719e6a4 earned-time-v0.2.0 (anki); shutdown projection prices each day on its own date
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 14:18:42 +02:00
275a0862fc shutdown: minutes end to end (*_MINUTES schedule keys, HH:MM helper args, to_hours removed)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:48:24 +02:00
ed5fa133d0 shutdown: derive base and bonuses from the shared earned_time registry
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
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
2026-10-04 18:55:51 +02:00
0b0cda6782 Morning-session reader moves to gatelock 0.9.0
_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
2026-10-02 14:05:40 +02:00
deb89b3e03 Relaxed days from freedays.WORKDAYS; delete the dead rtcwake mixin and its stale ALARM_DAYS
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:24:01 +02:00
0921146044 Tests can no longer write the live desktop Firebase credential
`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
2026-09-27 08:35:18 +02:00
6a07dc56aa Reading hour from book-guard: +1h shutdown; base 20->19 from 2026-10-01
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:02:24 +02:00
584177ec2e Lose Tue/Wed/Thu leniency when wake-alarm signals a missed prior-day ring
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:23:31 +02:00
9ab7c09c79 The early-bird window is the wake-alarm carrot, not a clock
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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
2026-09-20 08:39:44 +02:00
53bbdda419 Read the morning-session carrot from wake-alarm's signed file
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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
2026-09-19 16:41:17 +02:00
81284f160b Pin the clock in the sync-degradation tests; CI ran them inside the early-bird window
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:07:11 +02:00
c0099dff94 Refuse to build a lock screen over a workout already logged today
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:58:13 +02:00
9b84af7537 Count the manual budget per workout, keep a synced twin's id, and let a logged workout outrank an expired early bird
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>
2026-09-18 09:44:28 +02:00
a22ec380ca Credit RunnerUp runs by the day rule, from the WebDAV drop dir, and never twice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:14:17 +02:00
02a93f1322 Shutdown +1h on a day with a LeetCode solve; base moves 21:00 -> 20:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:06:26 +02:00
86b63108c7 Tombstone sync records durably: a ledger the PC re-pushes every tick
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
2026-09-13 20:20:06 +02:00
82f3462896 Replay today's earned shutdown credit in the daily base reset
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
2026-09-13 20:00:05 +02:00
967e3cd322 Key every workout day on the LOCAL calendar day, not UTC
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
2026-09-13 09:50:06 +02:00
9b39196671 Take gatelock 0.8.1: nested LockConfig
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
2026-09-12 19:52:38 +02:00
d6e8c0973f Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.

Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.

This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.

scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.

add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.

conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.

requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.

1256 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:41 +02:00
1d5965b9a7 Close the four gaps that let a decided lock never reach the screen
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:24:45 +02:00
8f911585c1 Serve the lock decision, its history and its health at 127.0.0.1:8770
The only status surface was a GTK tray icon, commented out of ~/.config/i3
on 2026-08-21. So when the weekly count went wrong, nothing on the machine
said so for two weeks -- the same silence that let a deleted timer job stop
enforcement for thirteen days in August.

Three read-only endpoints, and a React 19 UI over them:

  /api/status     the decision, the week, and whether today has a workout
  /api/decisions  the recorded trail, newest first
  /api/health     timers armed, disarm marker, log and trail freshness

The Why view is the one that matters: it renders the run of
"weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone
having to reach for journalctl.

/api/status also carries gaming.workout_today, which is the fact
steam-backlog-enforcer will cut the daily budget on. Deliberately just the
fact -- the enforcer owns the hour values, so the two repos cannot come to
disagree about what a workout day is. It is derived from count_day_credits,
the same rule the locker enforces with.

_compliance_state_word moves from status_view (tkinter) into
_status_projection, and the systemd arming check moves from
scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so
the server can answer "is enforcement armed" without a second copy of the
logic that would be free to disagree with the gate.

web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no
"strict" in any tsconfig, extends only tseslint recommended (no type-aware
rules at all), and gates neither lint nor typecheck in pre-commit. Here all
three run, on strictTypeChecked + stylisticTypeChecked, with
noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of
statements, branches, functions and lines.

typescript stays pinned at 6.0.3: 7.0.2 is out, but every published
typescript-eslint -- stable, canary and rc-v8 -- declares
typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry.

The 250-line cap now covers ts/tsx. It was python/shell/dart only, which
would have made web/ exempt from the gate the rest of the repo lives under
-- the same omission that exempted the whole Flutter app until 2026-08-15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 22:50:19 +02:00
dd8a39ade5 Record which log entries are two recordings of one workout
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
2026-08-29 22:31:32 +02:00
8ac7af158d Count one StrongLifts session once, not once per ingestion path
The locker did not fire on 2026-08-29, or for the two weeks before it.
Every 30 minutes it logged:

  DECISION lock=no reason=weekly_minimum_met weekly=5/5

The 5 was wrong. A StrongLifts session is ingested twice -- the phone sync
writes phone_verified, the PC session sync writes pc_workout_verified, both
from the same WorkoutSession payload -- and workout_id is "<type>:<date>",
so the two copies get different IDs and neither dedups the other away.
count_weekly_workouts then counted each entry individually.

Real totals against what was counted:

  week of 2026-08-17    5 counted    4 real
  week of 2026-08-24    5 counted    3 real

Both weeks were waived. Nothing reported a problem, because 5/5 is exactly
what a compliant week looks like.

Add credit_key/count_day_credits to _weekly_check as the single rule for
"does this entry earn a credit, and is it the same credit as another": the
two StrongLifts ingestion paths share one slot per day, everything else
stays per-entry so two runs or two manual workouts in a day still count
twice. Exported, so counters share the rule instead of re-deriving it --
re-derivation is what caused this.

Route _status_data._day_status through it too. It had re-derived the rule
as "verified + (1 if has_manual else 0)", which disagreed with
count_weekly_workouts on any day with two manual workouts; since that value
feeds the status view's weekly_minimum_met explanation, the window could
explain a decision the locker never made.

Two existing fixtures reached their counts using two same-day
phone_verified entries -- the double-count itself. They now use a gym
session plus a run, which is two real workouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 22:23:17 +02:00
ec0946f64e Grade against 3.14 too, not just run on it
`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
2026-08-29 11:42:47 +02:00
5d0c4d0e3a Cap --restore at 23:00 so it is a reward, not an off switch
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
--restore exists so the workout bonus can push shutdown later, and it had no
upper bound at all: `sudo adjust_shutdown_schedule.sh --restore 24 24 0` from
any shell undid the entire ratchet, with nothing written down. It is now
clamped to a ceiling and every restore is recorded.

Clamped rather than refused, so the reward path keeps working: the bonus asks
for up to 24 and simply lands on 23. Ceiling chosen by kuhy as today's earned
state -- base is 21, live was 23.

Two corrections to the previous commit, both found by reading the callers:

- It tightened validation from 0-24 to 0-23 on the grounds that the error
  message said 0-23. That was backwards. 24 is a real, documented value:
  _adjust_shutdown_time_by caps at 24 to mean midnight, and
  day-specific-shutdown-check.sh catches it through the morning-window
  condition. The message was what was wrong, so the message is what changed.
- The audit line does NOT go in /etc/shutdown-schedule-overrides.conf, which
  was the original plan. That file is parsed by day-specific-shutdown-check.sh
  as start|end|created|reason, and a line covering the current time makes it
  exit 0 and SKIP the shutdown. An audit trail written there would have
  suppressed the very curfew it documented. It goes to
  /var/log/shutdown-restore.log, and a test asserts the conf stays empty.

The log write says so on stderr when it fails, per this repo's no-silent-
failures rule; it is not fatal, because refusing an earned reward over an
unwritable log file would be the worse failure.

29 shell cases and 1146 python tests pass. Verified in a vmbox sandbox as root
against a real /etc and a real chattr: 24 clamps to 23 and is logged, the
overrides conf is untouched, and 25 is still refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5jMviyk15MKw1PqW1pzKr
2026-08-28 19:59:43 +02:00
2bc31cf587 Ratchet MORNING_END_HOUR, in the direction it actually runs
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
adjust_shutdown_schedule.sh compared only $1 and $2, so the third value passed
through unchecked in both directions: the morning shutdown window could be
shortened at will while the two evening hours were guarded.

The fix is not the obvious one. day-specific-shutdown-check.sh shuts down while
the clock is BELOW morning_end_minutes, so a LARGER MORNING_END_HOUR means a
longer shutdown window -- stricter. Copying the evening hours' `-gt` comparison
would have ratcheted it backwards and blessed exactly the loosening it was
meant to stop. The guard is `-lt`, and the inversion is now stated in the
header rather than left to be rediscovered.

Two smaller fixes in the same path: validation accepted 24 while its own error
message said 0-23, and the config is now unset before being sourced so an
unreadable file falls back to the documented loosest defaults instead of
inheriting whatever those names last held.

--restore is deliberately unchanged. It looks like a bypass, and it is one, but
it is load-bearing: shutdown_base.json says 21 while the live config is 23,
because the workout reward pushes the schedule later THROUGH --restore.
Bounding it at the base -- as was originally proposed -- would have capped the
reward at 21 and broken a daily flow. Bounding it needs a policy ceiling, which
is a decision, not a refactor.

The script is now sourceable, with the config path overridable, so the ratchet
has tests at all: 20 cases covering both directions, the source-clobber trap,
and the missing/unreadable config fallbacks. A ratchet with no test is how the
$3 gap survived for months.

Verified in a vmbox sandbox as root against a real /etc and a real chattr (the
immutable flag genuinely applied, which a tmpfs cannot do): a stricter write
lands, a looser evening hour is refused, a shortened morning window is refused
with the reason, and --restore still loosens. Not exercised against the host's
own /etc/shutdown-schedule.conf, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5jMviyk15MKw1PqW1pzKr
2026-08-28 19:28:06 +02:00
d3e4299223 Stub the HMAC signature so the manual-CLI test stops measuring the host
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:43:16 +02:00
06e48a7dc2 Detect a Firebase credential the server rejects, not just one that fails to build
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:32:39 +02:00
fa8fa208d8 Cover the headless dispatch and the degraded-source annotation
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Two gaps against the 100% bar. The first is mine: --log-manual-workout's
dispatch branch had no test, so nothing checked that the flag's own name is
stripped before the args are forwarded, or that the exit code is the logger's.

The second predates this work. `_record_decision` annotates the DECISION line
with any backend it could not read, which is the thing that distinguishes
"weekly=0/5, measured" from "weekly=0/5, unreadable" -- the exact confusion
that hid the 2026-08 outage -- and that branch was never exercised. Now both
directions are: named when a source is degraded, absent when none are.

test_sync_degradation.py was at the 250-line cap, so the new class lives in
test_sync_degradation_part2.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
2026-08-27 20:55:05 +02:00
9131c8086e Log manual workouts headlessly, with the form's rules intact
The phone form was the only way to log a manual workout, so logging one
depended on the phone's foreground. Another app taking focus mid-entry could
interleave a field (seen: "Played 6 games 1 did nPlayed 6 games...") or drop
one, which is a bad property for a record that spends budget.

`--log-manual-workout` takes every field as a flag and needs no UI, no phone
and no app. It is emphatically NOT a way to log a workout without evidence:
the draft goes through the same `validate_manual_workout`, so a missing or
too-short field refuses the whole run, writes nothing and exits non-zero with
the concrete reason. Sport-specific rules survive too -- table tennis still
demands its match counts, racket and balls.

Routed through `ingest_manual_records` rather than writing `log.json`
directly, so validation, the rolling 2/week budget, HMAC signing and
workout_id dedup are the shared implementation with the sync path instead of
a second copy that can drift. That is the difference from the deleted
`log_manual_workout.py`, which bypassed the checks: here the budget refuses a
third entry in a week exactly as the phone does, and re-running an identical
command is a no-op rather than a duplicate.

"Today" is UTC, matching how the budget windows compute it -- a local date
near midnight would file a workout in a different window than it is counted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
2026-08-27 20:50:52 +02:00
81ebccb51a Stop the phone counting deleted and empty manual workouts
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The app said "manual budget exhausted for this window" (2/2w, 4/10m) while
the PC read 1/2w, 2/10m for the same records. The PC was right; two
independent Dart defects each inflated a window by one.

`_readMergedPayloads` never looked at `Record.deleted`, so the tombstoned
duplicate `manual:2026-08-21T21:00` -- the second of two logged on 08-21,
already deleted -- kept consuming a weekly slot. Suppression is collected
across the whole device union and applied after the merge, not per file:
one device tombstoning a record while another still holds it live must
delete it, and a per-file skip would just let the live copy win. This
mirrors the PC's `_tombstoned_ids`.

`countManualBudget` also counted `manual_workout:2026-08-15`, a bare
`{type, kind, date}` stub with no evidence fields. The PC refuses to ingest
those ("no workout data to ingest; skipping permanently"), so the phone was
charging budget for a workout neither device ever accepted. The filter lives
in the counter rather than the shared reader because `runnerup_verified` and
`pc_workout_verified` payloads legitimately carry no `start_time`, and the
history view must keep showing them.

Covered by a shared literal fixture both languages read, rather than two
independent round-trips: each side's own tests passed while the pair
disagreed, which is exactly how this shipped. Verified by reverting each
guard in turn and watching the twin fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
2026-08-27 20:13:12 +02:00
075f7bae8c Cover the recovery branches, restoring the 100% gate
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The previous session reported the new code green, but measured it with
--no-cov (step 1 of finish_2026_08_24.sh passes that flag), so seven branches
in the recovery path had never been exercised. Coverage was 99.78%.

Most of the gap was the auto-recovery itself: whether healing succeeded,
whether the retry after healing succeeded, and what gets recorded when it
does not. That is the code which ran in production for the first time today,
and none of its outcomes were pinned.

Also covers the donor search on a machine with no ~/.config, siblings whose
cache is readable but holds no usable token, and the two healthy paths that
must stay silent — an always-on "this may be wrong" panel is one nobody reads.

Writing the UI test surfaced that collect_source_findings is never empty for
a fresh tmp_path, because an empty sync is itself a finding; the stub is
there so the branch under test is "nothing to report".

1115 passed, coverage back to 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
2026-08-24 13:18:01 +02:00
16ada6e5a2 Tell "could not check" apart from "no workout", and stop double-granting bonuses
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
2026-08-24 13:07:20 +02:00
589b9e227d Say every reason a lock decision had, not just the first one
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
The decision ladders are first-match-wins: the matching branch calls
sys.exit(0), so no later condition is ever evaluated or recorded. That made

    DECISION lock=no reason=early_bird_window_active

ambiguous in the one way that matters -- it cannot say whether the workout was
also already done, or the weekly minimum already met. Reading it, the 08:30
re-check looks like it is about to lock a machine that has nothing left to
prove, and the only way to find out was to re-derive the ladder by hand.

Keep the acting reason exactly as it was: it is the branch that actually
controlled behaviour, and it stays a stable grep-able slug that can be counted
across runs. Add the conditions that also held as an "also=" extra, which
as_line() already renders as key=value. A decision with a single reason still
renders the line it always did, so the common case needs no new format.

Only side-effect-free predicates are collected. _try_auto_upgrade_* writes log
entries and does ADB/network work, and _save_early_bird_pending writes state;
running either to produce a log line would turn a reporting change into a
state mutation in the enforcer. Verified that annotating leaves log.json and
early_bird_pending.json byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCUN9mdfJzJXqEZatHnUCD
2026-08-23 07:02:41 +02:00
4d1d2ea64d Put the demo's escape hatch inside the app, the only place it can be received
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
2026-08-22 12:41:07 +02:00
c3424059b2 Make the demo escapable, and stop claiming the blocking was deliberate
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
2026-08-22 12:33:07 +02:00
a8b34befb5 Lend the lock screen's grab to the workout app, without unlocking it
The locker cannot close() to let the app take over: close() also restores VT
switching, which would hand the user Ctrl+Alt+F2 and an exit from the very
obligation the lock exists to enforce. Only the grab moves; the Tk window
stays mapped underneath, so the screen is still covered if the app dies.

The watchdog has to be stopped for the duration, and measuring it is what
makes that non-negotiable. gatelock's recovery loop re-takes any grab it
finds missing every 1000ms -- by design, so that pulling a cable is not an
escape -- and holds_grab() is `grab_current() is not None`, which is False
once another X client holds it. Verified on a private Xvfb display: a naked
release is stolen back within ~1.5s, while stopping the loop first lets the
app keep the screen and the locker take it back cleanly afterwards.

The same harness confirms the ordering the handshake depends on: while the
locker holds the global grab a second X client cannot even finish connecting
(4s, no output at all), so there is no window in which the screen is
uncovered. It only gets through after the release.

Reaching into lock._recovery earns an SLF001 waiver rather than a public
pause on LockWindow: gatelock's _recovery.py carries a monotonicity proof
that no transition ever decreases the grab, and a public lend_grab() would
be exactly that transition. The waiver is documented where the other four
SLF001 waivers in this repo already live.

start_workout blocks the Tk loop for the whole workout, which is deliberate
-- pumping Tk underneath is what would let a tick steal the screen mid-set.
The docstring names the cost: a monitor hotplug is not handled until the app
exits and grab_input() re-covers every live output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 12:08:40 +02:00
006ef037f2 Supervise the workout app as the lock surface, on a deadline that holds
The supervisor hands the X grab to the Flutter app only once the app has
printed WORKOUT_LOCK: ready, so the screen is covered continuously and a
window that never started is never handed the machine.

Enforcing that handshake needs a timeout that actually fires. readline() on
a pipe blocks until a newline arrives, so polling the clock around it cannot
bound the wait: against a real subprocess a 2s deadline took 30s, and the
hang was then misreported as "exited before signalling ready" -- the wrong
diagnostic for the wrong failure. The wait is now select-based and reads
whatever bytes are available, since a half-written line would otherwise
block inside readline() past the deadline just as surely. Reads accumulate
before matching, because a chunk boundary can fall inside the marker and
dropping it would strand a healthy app at the lock screen.

Verified against real pipes, not fakes: a silent app and a partial line both
time out on schedule, the ready line is still detected, and a marker split
across two reads is reassembled.

Split into _workout_ready for the 250-line cap. The grab handoff and the
process hooks are injected as frozen dataclasses so both directions must be
supplied at the call site and a test that fakes the process also fakes the
wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 11:52:02 +02:00
a48bd1334a Credit StrongLifts sessions from the headless sync, as pc_workout_verified
`sync_now()` -- what workout-sync.timer runs every 15 minutes -- only ever
ingested MANUAL records. The session reader `pull_synced_workout()` was
reachable only from `_verify_phone_workout`, i.e. the interactive lock/status
UI, so a finished session earned nothing until a lock screen happened to open.

That was survivable while the phone was the only device recording sessions:
the same lock screen that demanded the workout also verified it. The Linux
build finishes sessions with no lock screen involved at all, so the credit
would simply never arrive.

`pull_all_session_records()` is the session counterpart to the existing
`pull_all_manual_records()` -- `pull_synced_workout` returns only the single
newest session for the live unlock check, which cannot backfill a week.

New type `pc_workout_verified` rather than reusing `phone_verified`: it is
machine-checked in exactly the same way (same WorkoutSession payload, same
set/rep and duration validation), but there is no phone involved, and writing
"phone_verified" into an HMAC-signed log would make the log lie about its own
provenance.

Ingestion re-derives the verdict instead of trusting the record's `succeeded`
flag -- the record arrives over the network and this is the thing handing out
unlock credit. Every rejection logs at WARNING with an actionable reason, per
the repo's no-silent-failures rule.

One real leak fixed along the way: formatting the duration with `.0f` rounded
34m59s up to "35 min", which is precisely the hidden accept bar -- the message
would have told the user the real threshold while rejecting them at it. It now
truncates, so the advertised 40 stays the only number shown.

Verified against live data: ingests the 4 real synced sessions, and a second
run ingests 0 (the write chokepoint dedups by workout_id), so the repeating
timer cannot double-credit. Suite green at 100% branch coverage, 1046 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 11:26:20 +02:00
2c206e5070 Adopt crdt-sync v0.9.0's bundled sync_log signature
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
sync_log's eleven flat keywords became SyncTarget / LogCodec /
RevisionTracking upstream. Call sites and test doubles follow; the hook's own
pinned crdt-sync moves to v0.9.0 too, so the gate checks against the API the
code actually imports rather than an older tag.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:28:14 +02:00
cc5075ba53 Cover _tombstoned_ids
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:43:34 +02:00
cdee896d43 Apply sync tombstones across the device union, not per device log
The previous commit dropped tombstoned records while reading each device
log, which was not enough: _merge_device_records unions across devices
keeping the highest HLC, so a record tombstoned on one device and still
live on another was simply absent from one dict and won the merge from
the other -- ingested exactly as before.

Collect tombstoned ids across every device log, then suppress by id
after the union. crdt-sync makes deletion monotonic inside its own merge
for this reason; this reader rolls its own merge, so it has to honour
the same rule itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:38:52 +02:00