The enforce loop now waits on an inotify watch of log.json's directory
(_file_wake, ctypes, no new dependency) instead of a plain sleep, so a
credited workout moves the gaming budget within the tick. Earner answers
(workout, LeetCode, reading) are memoised with the source file's
(inode, mtime_ns, size) stamp, so a rewritten file re-asks at once instead
of after the 60 s TTL. Any inotify setup or read failure warns and falls
back to the plain sleep; the stamp still catches the change next tick.
Tests: conftest stubs the wake factory and points workout_log_path at a
tmp path, so no test ever watches the real screen-locker tree.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
The 250-line split left function-sized copies behind: the confidence-only
batch fetch was the search half of _fetch_batch verbatim (now
_search_batch, and _SearchCtx carries one _HLTBExtras instead of two of
its dicts); the workout and LeetCode bonuses each had the same loopback
GET and answer cache (now _status_api); the poll-count refetch lived in
both _cmd_done and _polls_reporting (now refetch_poll_counts); the
candidate-selection prelude in both scanning and _scanning_assign; the
/etc/hosts rewrite loop in both block and unblock (now
rewrite_hosts_lines with a per-line rule); the timed HLTB fetch and its
rate line; and the whitelist constants each module only half used.
_hltb_cached's dead facade constants go too. Tests patch where the code
now lives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.
It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.
The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.
LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.
Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.
resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.
Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.
Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8