Commit Graph

225 Commits

Author SHA1 Message Date
fbf09fdfef Bring the workout app and status web UI up to latest stable
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
Pre-existing, and surfaced only because the pre-push CI mirror runs the
freshness gate over --all-files. Twelve findings across three ecosystems,
none of them related to the free-day work they were blocking.

Flutter: permission_handler 13.0.2, vibration 3.2.1, and
very_good_analysis ^11.0.0. The major analyzer bump newly flags 10
async_return_with_no_await sites across the Firebase, storage and sync
services; each is now `return await` rather than suppressed. Without the
await the future escapes the enclosing async function, so a failure
surfaces at the caller with a stack trace that no longer names the code
that made the call -- which is precisely the wrong behaviour in a sync
service whose errors are meant to be caught and retried locally.

npm: vitest and @vitest/coverage-v8 4.1.11 -> 5.0.0 (the pair has to move
together), plus eslint, typescript-eslint, globals, the @types packages
and user-event. The major vitest bump needed no test changes.

flutter analyze clean, 411 workout-app tests pass. vitest 52 tests pass
on 5.0.0, eslint clean, tsc --build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:46:07 +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
d35097f461 Give CI the node packages its web hooks have always needed
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
The pre-commit workflow has been red since 8f91158 added the status UI: it
installs Python and Flutter deps but never node ones, so `tsc --build` could
not find the 'vite/client' and 'node' type libraries and failed TS2688 on
every run. Nothing was wrong with the code; the job simply could not build it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJUtseRczWVBJW8LvL1aQH
2026-08-30 15:32:51 +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
254d9b3587 Spell "unparsable" the way codespell wants
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 repo-wide codespell hook only reaches this on --all-files, so the
pre-push CI mirror caught it after the per-file run had passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 23:13:33 +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
ee205dc302 Take pylint 4.0.8
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 freshness gate blocks pre-push on any pin behind latest stable, and
this one predates the counting fix in the previous commit -- it was simply
the next push to notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 22:24:13 +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
ae13eeb572 Take coverage 7.16.0
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
Already red before today's Python work touched the repo; the commit hook had no
reason to look at requirements.txt, so only the whole-tree run saw it. Bumped
across every repo pinning 7.15.4 in the same pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:42:53 +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
b9c2bf796e Run CI on exactly one Python, the newest
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 19:20:48 +02:00
e32b9c5c66 Refresh the gate workflow from the shared template
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 (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-28 16:55:12 +02:00
6b756c8da2 Give the pre-commit workflow the shared-gate checkout it needs
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 (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
Both delegating hooks were exiting 1 with "shared gate not found at
/home/runner/utils/scripts/...". The workflow runs `pre-commit/action` against
a bare checkout, so nothing put kuhyx/utils where the delegates look. It now
checks the shared repo out to `.utils` and points `UTILS_ROOT` at it, the same
way every other workflow in the fleet does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 15:19:11 +02:00
d67c770cc9 Latest stable dependencies, and the gate that keeps them there
Python side: gatelock v0.7.1 -> v0.7.2 and crdt-sync v0.9.0 -> v0.9.1 (both
re-cut so consumers stop resolving pre-upgrade constraints), plus every `>=`
floor turned into an exact pin -- five majors among them (mypy 1 -> 2.3.1,
pylint 3 -> 4.0.7, pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, ruff 0.8 ->
0.16.5) and mcp pinned at 2.1.1. `scripts/ci_mirror.sh` builds a clean venv
from requirements.txt and runs the whole CI set: 1146 tests, 100% branch
coverage, no lint or type finding.

workout_app: sync_settings_ui v0.1.0 -> v0.4.1 and ten exact pins, with the
flutter_secure_storage 11 chain (compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1).
411 tests green, analyzer clean, release APK builds.

CI's Flutter pin was 3.44.6 while `.fvmrc` says 3.47.2 -- the drift that made
kuhylog red on a commit that is clean locally. Both workflows now say 3.47.2,
and the gate reads that pin from now on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 15:13:11 +02:00
d8bb8943eb Fix the popup-safety check: pinned gatelock API, and its own empty log
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:53:18 +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
e2d08f33a7 Add the removal marker to every TODO file
Some checks failed
md-naming / markdown naming convention (push) Has been cancelled
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 rename commit staged the git mv but not the accompanying content
edit, so these TODO files landed without the "REMOVE ME AFTER FINISH"
line the md-naming gate requires. Repos whose hook was already live
refused that commit; repos without it accepted a file the gate would
now reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:56:24 +02:00
a889341979 Adopt the four markdown namespaces and gate them
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.

TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:53:42 +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
f4850670ee Make the recovery scripts say why they failed, not just print it
The three helper scripts were only ever dry-run, so the no-silent-failures hook
never saw them until they were committed. Six handlers reported the reason to
stdout via _report() and nothing else — invisible to the log, and easy to miss
when the script runs as one step of finish_2026_08_24.sh amid a wall of other
output. These mutate benefit and sick-day state, so a quiet failure there reads
as success.

Log at error/warning alongside the stdout line, saying what was not done:
the bonus was not banked, the revocation was not persisted, the credit gate
cannot tell and so fails closed.

Verified by feeding the gate a corrupt log: it now logs the reason with a
traceback and exits 1, instead of printing one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
2026-08-24 13:10:53 +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
5438ccad25 Install the runtime deps, and stop hanging a non-interactive install
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
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
2026-08-24 12:43:31 +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
7b61619a7a Reconcile the venv on launch, so a pin bump cannot sit unnoticed
run.sh created .venv on first run and then only ever launched the app. When
the crdt-sync pin moved to v0.9.0 nothing ran pip, so the venv sat at 0.6.0
for months and surfaced as an ImportError on LogCodec at startup. The venv is
isolated (ENABLE_USER_SITE=False), so it could not fall back to the healthy
0.9.0 in user site-packages that the systemd units use.

Reconcile against requirements.txt and refuse to launch if that fails, rather
than starting on dependencies we already know are wrong.

Gated on a sha256 stamp of requirements.txt: pip re-resolves the two git pins
over the network (~5.5s) on every call, and this is a screen locker that has
to start when the network is down. The stamp is only written on success, so a
failed install retries on the next launch instead of being remembered as done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCUN9mdfJzJXqEZatHnUCD
2026-08-23 06:56:26 +02:00
c4cc39759a Cover the lock-mode flag, and mark the one branch Linux cannot reach
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 Flutter coverage gate blocked the push on 8 lines, all from the desktop
target's commit. Seven of them are one branch: didChangeAppLifecycleState
returns on its first line under Platform.isLinux, and the suite runs on Linux,
so the switch below it is unreachable from any test this repo can run.
Covering it would mean threading a platform seam through the nine
Platform.isLinux checks in this app for test reachability alone, so it is
marked instead -- the same treatment, and the same shape, as
storage_service_schema's platform edge.

The eighth was just untested: parseLockMode is a pure function, and it now has
the cases that matter, including the flag arriving next to --demo-escape.
main_lifecycle_test covers the observer's initState/dispose either way.

408 tests, 2759/2759 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 13:06:49 +02:00
2f95c6cb69 Stop the history tests reading the developer's own Firebase account
14 tests failed with a RemoteSyncError from listDirectory, and the cause was
not in the screen they were testing. The sync guard short-circuits on "no
backend configured", but it asks that question two ways: the sync token AND
whether a Firebase account exists. The tests faked the token into secure
storage and stopped there, so on a machine holding a cached refresh token the
real factory answered yes, the guard opened, and the fetch escaped to the
network -- where flutter_test blocks HttpClient.

That went unnoticed until the Linux desktop target landed, because the desktop
build is what authenticates from the shared refresh token: the same tests pass
on the commit before it, on this machine, this minute.

So the fixture now pins firebaseFactory to "no account" by default, which is
what the faked token was already trying to express, and HistoryScreen takes it
as a tests-only seam next to the httpClient one it already had. The two direct
constructions in workout_sync_read_test.dart are pinned the same way.

402 Flutter tests pass and flutter analyze is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 13:01:18 +02:00
c05dc9c224 Make disarming survive the install scripts, not just systemd
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
2026-08-22 12:54:13 +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
f65cb150aa Add a hand-testable demo of the workout handoff, with the escapes left open
Trying the handoff by hand on the production lock means a global grab and VT
switching disabled -- if the Flutter app wedges, there is no way out. This
builds the same handoff against a demo-mode lock instead: grab="local", VT
switching enabled, an Escape binding and a close button, so three independent
escapes survive a hung app.

Verified end to end under Xvfb with the real debug bundle: the grab is
released, the app maps and prints WORKOUT_LOCK: ready, and closing it returns
the grab to the lock window with the status line updated.

Says in its own docstring what a green run here does NOT prove: under a local
grab gatelock's watchdog early-returns and never re-takes the grab, so the
1000ms steal-back that makes _recovery.stop() load-bearing in production is
not exercised by this script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 12:17:11 +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
186bfcabc4 Run the workout app on Linux, and let it hold the screen lock
The phone is lost, so no workout can be recorded and enforcement has been
disarmed since 2026-08-21. This gives the PC a first-class way to do the
workout, reusing the existing app rather than reimplementing the progression
rules a second time -- two implementations would guarantee the divergence the
"same weights everywhere" requirement forbids.

Storage: sqflite has no Linux plugin, so main() installs sqflite_common_ffi's
factory, which runs the same schema and migrations against the same SQL. It
was already a dev dependency driving the test suite; this only promotes it.

Auth: the account password in ~/.config/crdt-sync no longer authenticates
(verified: HTTP 400 INVALID_LOGIN_CREDENTIALS). The PC fleet has been running
on the cached refresh token all along, so the desktop credential store reads
that same 0600 file -- the Dart and Python credential JSON keys are already
identical -- and passes null instead of the stale password. This also avoids
google_sign_in, which ships no Linux implementation.

Lock mode: --lock-mode makes the window the lock surface. There is no Dart API
for this, so the runner sets override-redirect and takes an exclusive
gdk_seat_grab. Two details cost a debugging cycle each and are commented in
place: override-redirect must be set BEFORE the first map, or the window stays
mapped and never repaints; and gtk_window_fullscreen() only ASKS the window
manager, which cannot see an override-redirect window -- so the geometry is
set from the monitor directly, otherwise a strip of live desktop is left
uncovered at the edge of a larger screen.

The grab is acquired by retry rather than by asking the supervisor to release
first: the Python locker keeps its grab while this window maps on top, so the
screen is covered continuously instead of exposing the desktop for the whole
of Flutter's cold start.

Verified against the live data: pulls all 7 exercises from Firebase (Bench
17.5x12, RDL 27.5x8, Row 25x6, Situp 10x31 -- the real weights, not factory
defaults), leaves the remote progression byte-identical, and mints its own
device id rather than reusing the phone's. A Tk client is refused the grab
while the lock holds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 11:18:48 +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
590f606856 Honour crdt-sync tombstones when ingesting synced workouts
_records_matching read every record's payload and never checked
record.deleted, so a tombstoned workout was ingested like a live one.
Deleting a workout in the phone app therefore looked like it worked and
came back on the next 15-minute sync, silently re-crediting it.

crdt-sync deletion is monotonic (a tombstone cannot be resurrected by
merging an older, non-deleted copy), so this is the only durable way to
retract a synced workout across devices. Skip tombstoned records and say
so at info, since "this stopped counting" is exactly the kind of thing
that must not happen silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:35:53 +02:00
426b9372be Opt in to preempting a weaker gatelock holder
gatelock just made preempt_weaker_holder opt-in (it defaulted to True,
which let wake_alarm evict this armed lock). screen_locker is the app
that actually needs it: on 2026-08-21 diet_guard held the global grab
from 07:04 to 09:07+ while this locker retried against it, which is the
contention behind the reported lockout.

Production only -- demo mode uses a local grab and never contends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:31:57 +02:00
390e916922 Finish the RunnerUp sport-allowlist removal; isolate the instance lock in tests
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
A prior commit (6a7a7d8) accidentally captured only the _constants.py
half of an already-in-progress, uncommitted change removing
RUNNERUP_ACCEPTED_SPORTS -- leaving main broken (import error) since
_runnerup_verification.py and its 15 test files still referenced the
now-undefined constant. Committing the matching half: RunnerUp
entries are now judged purely by distance/duration, any sport
qualifies, matching how every other workout type is already treated.

Also: the new single-instance lock was not test-isolated, so every
ScreenLocker built across the suite contended for the same real
$XDG_RUNTIME_DIR/gatelock file -- only the first test won it, the
rest silently exited. Added to conftest.py's isolated-state table
(split into tests/_isolated_state.py to stay under the 250-line cap)
and added dedicated coverage for screen_locker/_instance.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:17:26 +02:00
957e4566c2 Bump gatelock pin in pre-commit's pylint hooks to v0.7.1
Missed alongside the main dependency bump -- pre-commit's pylint
hooks pin gatelock separately via additional_dependencies, so they
were still resolving wait_for_turn against the old v0.4.0 tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:09:34 +02:00
6a7a7d8933 Queue behind stronger lockers, single-instance guard, fix budget-crash in fit check
Root-caused the 2026-08-21 stuck-lock incident: a manual workout
unlocked correctly, but the mechanism behind "locked again seconds
later" was a stray workout-locker.service re-arm racing a second
process into building a competing lock window -- not a leetcode-guard
conflict as first suspected (its timer wasn't due until 13:00 that
day). Separately, diet_guard held the global grab for ~3 minutes
against a newly-arriving, higher-ranked screen_locker that morning,
which just retried and logged instead of making progress.

- _build_lock_window now calls gatelock.wait_for_turn before arming,
  so a stronger incumbent is queued behind instead of raced.
- New screen_locker/_instance.py (flock-based, mirrors
  leetcode_guard/diet_guard's own single-instance guards) stops a
  second process from ever building a competing window in the first
  place.
- Bumped to gatelock v0.7.1, which adds weaker-holder preemption
  (SIGTERM, reusing the holder's own clean close() path) so a lower-
  ranked incumbent can no longer block a stronger claim for minutes.
- Fixed a live crash in scripts/verify_screen_fits.py: it read the
  real log.json, so today's exhausted manual-workout budget (itself
  caused by the incident's duplicate entry) made the fit-check crash
  on an unset _mw_error_label. Points at a path that can never exist
  instead, so the measurement no longer depends on live data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:04:54 +02:00
c1c73d43ba Stop the relaxed-day popup from re-firing every 30 minutes after Skip
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
workout-locker.timer (added in e5db254) re-runs the full lock-decision
chain every 30 minutes, but is_relaxed_day() is a pure date check with
no memory of an earlier same-day dismissal. Clicking "Skip — No
Penalty" only closed the window without persisting anything, so the
next tick re-derived reason=relaxed_day and rebuilt the same popup.

Now the skip button writes a relaxed_day_skip marker to the log
(excluded from weekly counting and sync, same treatment as heat_skip),
and the startup chain checks for it before re-entering the relaxed-day
branch. Also renames workout_log.json to log.json throughout the repo,
tests, docs, and Dart comments, since the fix touches that file anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qH2hJ6mvCJd3ZXpAJSaF7
2026-08-18 21:55:59 +02:00