Requiring 100% locked the user onto one game for weeks, which proved
ineffective. The assignment and every manual pick now release as soon as one
achievement is unlocked after the game was assigned (Steam unlock_time vs the
pick's started_at / State.current_assigned_at).
- finished_app_ids still means 100% complete; a game released below 100% gets
released_at plus a 7-day cooldown and returns to the pool.
- Next pick: least recently assigned first, then shortest HLTB time.
- Releasing never evicts: released/expired picks stay installed and playable
until the user explicitly chooses another game (new pick-manual, or
accepting the next assignment); with no replacement the current game stays.
- Tampering detection judges a released game by unlocks after its release.
- The daemon reloads every State field each tick so its sweep save cannot
overwrite cooldowns or assignment times written by the CLI.
- State.load backfills assignment times once and persists them; load-time
migrations moved to _state_migrations.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
require_snapshot_games / snapshot_game_name (_snapshot), apply_configured_pick (_actions) and hide_others_and_report (library_hider) replace copies spread across the CLI, MCP and stats paths. Tests retargeted to the new owners; test_main_pick_manual split to stay under 250 lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D55DvFEHiwcPN59kwPqwiU
SDL3's HIDAPI lg4ff driver advertises an effect-status query it cannot
answer; BeamNG.drive 0.39 native treats the failed query as a dead effect
and the wheel goes limp (12k "Failed to get joystick3 FFB effect status"
lines per session). Steam's environment on this box is exactly this
allowlist, so the hint has to live here, not in /etc/environment.
The narrow hint only demotes the Logitech wheel to the kernel driver;
gamepads keep HIDAPI (rumble, LEDs over Bluetooth).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
Steam's srt-logger runs inside steam-backlog-enforcer.service's cgroup
and mirrors Steam's console into journald on its own — the DEVNULL
stderr the spawn already uses does not reach it, and the env carried
SRT_LOG_TO_JOURNAL=0, which is not the variable it reads. One boot on
2026-09-12 wrote 481k "joystick FFB effect status" lines (99% of the
journal) and evicted two weeks of history from a 300M journal, along
with the evidence for that night's curfew failure.
- desktop_env_args(): SRT_LOGGER_USE_JOURNAL=0, the variable srt-logger
actually honours. Steam keeps its own logs/console-linux.txt.
- Unit: LogFilterPatterns for the two known flood patterns plus a
per-unit rate limit, so a future Steam that ignores the variable still
cannot take the journal with it.
The unit file is deployed and daemon-reloaded; the running service is
not restarted, because Steam is its child and a restart would kill a
live game. The env fix takes effect on the next enforcer start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mLnUeoJ3mUwGRTowTUH6d
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 pinned howlongtobeatpy (1.0.23) takes a user agent in
send_website_request_getcode and dropped parse_all_scripts; the call
still matched 1.0.20, which only worked because the deployed env was
behind its own pin. Now on the pinned API, and the env upgraded.
Three modules carried the same executable/path constant block since the
250-line split; it is _store_tools. Four carried the same "restore the
hours a refetch lost" loop; it is restore_prior_hours. Both detached
launches go through one helper that returns the process. Thirteen
try/except/else blocks are flat, dataclass fields come from
dataclasses.fields, and the rest are one-line fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
hltb, _cmd_done, _whitelist and _actions each kept a module __getattr__
that served names moved out in the 250-line split, via importlib to
dodge the resulting import cycle. pylint cannot see through that, and
graded every such import as no-name-in-module (26 of them). The 32
import statements now name the real module; with nothing left going
through the facades, they are gone.
Also the pylint hook on 4.0.8 with the source-only scope config, the
three implicit-booleaness disables the reference repos carry, the
http.server do_GET admission, and the third-party pins the hook was
missing (aiohttp, websockets, howlongtobeatpy). Gate still 8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Also bumps mcp 2.2.0, ruff 0.16.7, types-requests 2.33.0.20260906 and, in
web/, react 19.3.0, vitest 5.0.0 (+ coverage-v8), eslint 10.10.0,
typescript-eslint 8.70.0, vite 8.3.0 and the small ones, so the freshness
gate lets this through. Web lint/build/tests/coverage (100 %) unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
Keeps the dependency-freshness gate green; unrelated to the engagement
gating removal in an earlier commit. Not installed in this dev
environment (pip is externally-managed here), so this is a manifest-only
bump — CI installs it fresh and is the strict authority per the
dependency-freshness rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the dependency-freshness gate green; unrelated to the engagement
gating removal in the previous commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A qualifying game process running is now sufficient on its own to bill
the budget. Previously a tick only counted if the screen wasn't locked,
the game's window was X11-focused, and input hadn't gone idle for 5
minutes — with an idle-grace refund on top. That leniency is gone
entirely: no focus check, no idle check, no screen-lock check, no
refund. Deletes the engagement-probing modules (_playtime_engagement,
_engagement_probes, _engagement_types, _x_focus, _x_probe, _screen_hold,
_controller_input) and their config fields, simplifies the audit log
and budget-view/web session schema accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
report_leetcode_incident appends to leetcode_bonus_incidents.log at the repo
root -- a real artifact you read to find out why an hour went missing.
test_budget_resolve.py drives the fail-closed path without stubbing it, so the
first full run put two fabricated incidents into the live file.
The file is gitignored, so nothing would ever have failed to warn about this.
It was caught by reading the file after deploying, not by any test.
Redirected with an autouse fixture rather than per-test patches, for the same
reason _isolate_filesystem is autouse: enumerating the tests that need it is
the version of this that breaks the next time someone adds one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
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
A server started as `python -c "from ..._web_server import serve; serve(
port=8000)"` ran for five hours after the code beneath it changed, reporting
an 8h gaming budget while the daemon enforced 6h and had already cut gaming
off. Two separate holes let that happen.
The staleness check only ran from cmd_serve, so a server started any other
way never met it. And is_our_server matched argv containing
"steam_backlog_enforcer.main", which a `python -c` process does not have --
so that server could not even be identified as ours: `run.sh serve` would
have refused the port and named it as a stranger rather than replacing it.
_serve_stale puts the check inside the server, where no launch path can skip
it. Every route, static included, returns 503 once any package .py is newer
than this process, because a fresh page fetching stale numbers is the same
lie with extra steps. The answer is memoised for five seconds so it does not
stat the package per request; that bounds the staleness window instead of
leaving it open forever.
Refusing is only half a fix -- a process that 503s forever is honest and
useless -- so a refused request also stands the server down. The new
steam-backlog-enforcer-web.service supplies the other half with
Restart=always: it dies on stale code and returns on current code with
nobody having to remember anything. A user unit, not a system one; it serves
read-only views and needs no privilege. install.sh installs and enables it
rather than printing instructions.
is_our_server now substring-matches the joined argv, so run.sh serve,
python -m ...main serve and a bare python -c are all recognised.
newest_py_since moves into _serve_stale and _serve_guard delegates to it.
Both needed the same scan, and two copies would have been free to disagree
about whether a server is current -- which is the entire failure being fixed.
Verified end to end: healthy 200, touch _budget_view.py, next request 503,
systemd replaces the process, back to 200 reporting 6.0h and blocked=True.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
8h is now earned rather than given. A gaming day with a counted workout
logged in screen-locker gets daily_gaming_seconds; a day without gets the
new unearned_gaming_seconds floor. Skipping a workout costs two hours the
same day, instead of nothing until the weekly lock fires.
The fact comes from screen-locker's GET /api/status, which reports only
workout_today -- the hour values live here. Neither repo can drift from the
other about what a workout day is: that flag is derived from
count_day_credits, the same rule the locker enforces with.
Every way of not getting an answer applies the floor, logged at warning with
the URL and the concrete error. Failing open would let the coupling be
defeated by stopping one user service, and a silent 8h would be
indistinguishable from an earned one.
resolve_budget_seconds is called from rules_for rather than at each call
site, because the daemon holds one Config for its whole life while
_budget_view reloads it per HTTP request. Resolved at only one of those and
the web UI would report a budget the daemon was not enforcing -- the exact
silent divergence this codebase is built to prevent.
A budget that rises mid-day now releases the block properly.
_release_after_raise clears blocked_at, which otherwise only roll_over reset
and which would leave the UI reporting "blocked" while gaming worked, and
clears warned_seconds, which is keyed on seconds *remaining* and would
otherwise let the extra two hours arrive with no warnings at all.
Known and accepted (2026-08-29): the budget can DROP mid-day if the resolved
answer changes under a day already in progress, re-pricing time already
spent and cutting off on the next tick with no warning. The module docstring
says so plainly rather than claiming a monotonicity the code does not
implement; the fix, if it ever bites, is a persisted granted_budget_seconds.
Tests get an autouse stub for the lookup. Without it every test that builds
rules makes a real loopback request, and the suite would pass in the morning
and fail in the evening depending on whether the machine's owner had trained.
test_budget_view builds its RULES at import time, before fixtures apply, so
it stubs the resolver explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
CI failed at 99.98%: _serve_guard.py:125-126, the `except OSError: continue`
in the fd scan, was never executed there. The test planted one unreadable
entry beside a real socket symlink and asserted the socket was still found —
which only exercises the skip if iterdir() happens to yield the broken entry
first. It does locally; on CI the socket came first, the function returned
early, and the skip never ran.
Now split in two: one case with only unreadable entries, which hits the skip
whatever the order, and one that keeps the original assertion that a broken
fd does not make the scan abandon the process.
The private guard helpers are also imported directly rather than reached
through the module, which drops 20-odd pylint protected-access warnings and
takes this file from 5.75 to passing on its own. The repo's pylint gate scores
an aggregate against --fail-under=8.0, so a file that is only ever committed
alongside others can hide below it; this one no longer needs the company.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
The serve rework was sitting untracked: a /proc-based port guard, the
start-or-defer decision, and a stale-bundle rebuild. It had no tests, and it
changed cmd_serve(_config, state) to cmd_serve(argv) without updating the one
test that called it, so the suite was red and coverage was 251 lines short.
Tests, all against fakes:
- _fake_proc builds a synthetic /proc. _NET_TCP is derived from _PROC at
import time, so patch_proc redirects both; patching _PROC alone would have
had these tests parse the host's real process table.
- _serve_guard is split across two test files to stay under the 250-line cap.
os is replaced wholesale in every termination test, so no signal can reach a
real process even if the guard's self-protection were removed.
- _web_build mocks subprocess entirely and redirects every path constant into
tmp_path. _no_subprocess now blocks _web_build.subprocess.run too: an
unguarded test would fire a real `npm run build`, rewriting web/dist and
perturbing frontend_is_stale() for every later test in the run.
Source changes, both required rather than incidental:
- _serve_guard's wildcard match was a `# noqa: S104` on a "0.0.0.0" literal,
which the no-noqa hook rejects outright. It is now the all-zero hex column
/proc actually prints, so the dotted-quad literal never appears.
- main/__init__.py's serve dispatch arm was itself uncovered; it now has a
test asserting the raw argv passes through unparsed.
Also drops the document.hidden guard from useBudgetPoll: it skipped the very
first load, so the budget panel sat on "Reading budget state…" forever in a
background tab, which is every tab under browser automation.
Verified: 1712 python tests, 100% branch coverage; 128 vitest, 100%. The
guard's /proc parser was checked read-only against a real listener on :8000
and resolved the same pid as `ss`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.
qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.
Two things the parts/whole relationship forces:
- per_game only ever sums to AT MOST seconds, never equals it. backdate
refunds the idle grace on every engaged-to-paused edge, and
_state_or_recover synthesises a full budget with no breakdown. The
refund is debited from last_credited_key, and the remainder is rendered
as "Unattributed" rather than stored — one path then covers legacy days,
focus-probe failures and drift alike.
- counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
frozenset is also the total-block kill list, and it routes through
kill_and_uninstall_launchers, which pacman -R's the owning package.
Counted entries are killed on total block, never uninstalled.
Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.
Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.
Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
`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: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041),
deprecated `typing` imports (UP035), PEP 695 `type` aliases (UP040), 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).
ruff, ruff-format, mypy --strict and pylint all clean at the new target. The
two modified web/ TypeScript files are another session's in-flight work and
are deliberately left unstaged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
The cutoff badge asserted "launchers are masked" from `blocked` alone, but
the two can disagree: the total-block branch of `playtime_tick` calls
`reconcile(should_block=False)` and leaves `blocked_at` set, so the mounts
are gone while the day still reads blocked. The panel already fetches
`rules.masked_launchers` for its "Masked now" row, so the badge now reads
the same live count instead of inferring one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dependency-freshness gate was already failing on both workflows before
this branch touched anything, so `git push` was blocked for reasons unrelated
to the change it was carrying:
* python-tests.yml ran a ["3.11", "3.12", "3.13", "3.14"] matrix, and a
matrix is not a version -- there is nothing for the gate to compare.
* pre-commit.yml was pinned to 3.12, two minors behind.
Both now run 3.14.7, matching the interpreter the code is developed against.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CEsG6BzHtYR9cHcdxPZqG
A running game process was treated as proof of play. On 2026-08-28 the budget
billed ~2h23m while a gatelock screen locker covered the display and the
machine could not be touched at all -- the game was simply still resident.
A tick now bills only when all three hold, evaluated together rather than
short-circuited so the audit log can answer "was the screen ALSO locked?":
* no gatelock guard owns the screen (_screen_hold, via /proc/locks)
* the focused window belongs to a qualifying process or its ancestry
* some input source has been used within idle_grace_seconds (default 300)
Any probe that cannot answer BILLS the tick and is recorded in `degraded`.
A broken detector must never become an unlimited-gaming exploit.
Three findings from measuring this machine rather than assuming:
* /dev/input/js0 here is an *ASRock LED Controller* -- the kernel exposes
the motherboard RGB device as a joystick. A presence check would have
disabled the idle criterion permanently, so _controller_input watches for
real events instead (skipping joydev's synthetic open burst).
* Aseprite's window carries no _NET_WM_PID at all, so focus attribution
falls back to XRes, which asks the server who owns the window rather than
trusting the client to have announced itself.
* /proc/locks reports gatelock's hold without taking it; gatelock's own
read_claim_if_held detects held-ness by acquiring, which would race a
guard every 3 seconds.
Idle pauses also refund the grace period once per walk-away, on the
engaged->paused edge only, never across a day rollover, and never on the first
tick after a restart -- the only place the counter moves backwards.
Everything is recorded to /var/log/steam-backlog-enforcer/budget.jsonl, on
change plus a heartbeat rather than one line per 3s tick. Records name the
qualifying PIDs: reconstructing this incident from journald alone was only
possible by inferring accrual from the spacing of two warning notifications.
Also: refuse to bounce Steam while a game is running. ensure_steam_debug_port
relaunches Steam when the CDP port is closed, which kills the running game --
that is how restarting the daemon to deploy this change ended a live session.
It now defers exactly as it already did for in-flight game updates.
Talks to X through ctypes over one cached connection: at a 3s tick, forking
xprintidle and xprop would cost ~40 forks a minute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CEsG6BzHtYR9cHcdxPZqG
Both tasks it describes are done, verified live and pushed: manual picks can
be abandoned at any age with the cap at 2 (737b492), and every file in the
repo is under 250 lines (dd480f3, 2ff39ed). A finished task file is deleted,
not left behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
_web_dataset.py 436 -> 178 + _web_models, _web_games, _web_pace
The six dataclasses (WebGame, WebStateInfo, WebDefaults, DefaultSummary,
PaceVsHLTB, WebDataset) are used by BOTH the parent and the helpers, so they
go to their own leaf model module that both import. Leaving them in the
parent is what created a circular import and broke ./run.sh on the previous
attempt.
All public names plus the seven private helpers tests reach for are
re-exported from _web_dataset via __all__, so import paths are unchanged.
Test patch targets split per class: TestBuildWebDataset, TestDatasetToPayload
and TestDatasetEndpoint still patch load_snapshot on _web_dataset (that
function did not move) while their cache patches move to _web_games.
`bash scripts/check_file_length.sh --all` now exits 0 — no file in the repo
is over 250 lines. 1344 tests, 100.00% coverage. Verified live: ./run.sh
status works and build_web_dataset projects all 958 games with pace and
summary sections intact; service at 0 restarts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
Leaf-set extraction only, so every new import is one-way and no cycle is
possible:
_stats.py 523 -> 233 + _stats_types (the shared _GameTimes dataclass,
imported by parent and helpers alike),
_stats_gathering, _stats_display
scanning.py 521 -> 248 + _scanning_candidates, _scanning_assign,
_scanning_tampering
_GameTimes and the four moved scanning helpers are re-exported from their
original modules via __all__ (mypy --no-implicit-reexport needs them listed),
so callers and tests keep their existing import paths.
Test patch targets were retargeted per file from the pytest failure list —
the target is the module whose source now contains the function under test.
Note TestPrintScenario and TestDoCheck did NOT move, so they keep the
original target while the rest of their files change.
1344 tests, 100.00% coverage. ./run.sh status and ./run.sh stats verified
against the live install; service at 0 restarts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
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
The 4-day grace window left kuhy stuck: three picks locked in, the window
closed on two of them, so nothing could be abandoned and no new pick could
be added.
- abandon-pick now works however old the pick is. MANUAL_GRACE_DAYS,
manual_pick_grace_remaining() and can_abandon_manual_pick() are deleted;
abandon_manual_pick()'s existence check is the single remaining guard
(the previous post-guard and its "race" test became unreachable).
- Config.max_manual_picks 3 -> 2. kuhy's config.json does not set the key,
so the default change is sufficient.
- status/lock/MCP surfaces report informational age ("picked N day(s) ago",
age_days) instead of a grace countdown. Note this is a sign flip, not a
rename: age is now - started, where grace was deadline - now.
Verified live: abandoned Montaro (495890) and Battle Bruise (618510), both
previously refused as EXPIRED; pick-manual 3164500 now reaches its confirm
prompt at 1/2 slots used. 1344 tests, 100.00% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
_hltb_search sheds name matching (_hltb_matching) and the search request
itself (_hltb_search_api); _hltb_detail sheds page parsing
(_hltb_page_parse) and completionist/DLC extraction (_hltb_comp_extract).
Every extraction is a leaf set -- functions that call nothing else in their
module -- so each import stays one-way and no cycle is possible. Choosing
boundaries any other way is what made the earlier scanning/_stats attempt
fail, and those two are deliberately left for a later pass.
1347 tests, 100.00% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
aecfd1a moved _block_store_iptables into _store_iptables but left its tests
patching store_blocker.socket, which no longer exists there. My mistake: I
read a --no-cov run as green when it was not, and committed 3 failures.
1347 tests, 100.00% coverage, verified with the coverage run this time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
Extract only functions that call nothing else in the module: the iptables/DNS
layer and the /etc/hosts protection helpers. A two-way split here does not
work -- block_store calls _reblock_hosts while _unblock_hosts calls
is_store_blocked, so cutting between them makes the import circular. Choosing
the leaf set instead keeps every import one-way and needed almost no test
churn, where the earlier attempt cascaded into a dozen patch-target fixes.
1347 tests, 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
Each keeps its name and serves the moved names through a module __getattr__
(PEP 562) rather than a top-level re-export: every child imports back from
its parent, so a plain import would be circular. The lookup goes through
importlib.import_module, which is a call rather than an import statement,
so it needs no linter suppression. Return type is a _Reexport alias -- mypy
rejects `object` as not callable at the call sites, ruff rejects a bare Any.
Also fixes two leaks where tests wrote to the user's real config, both
caused by a redirected constant moving to a new module while conftest kept
patching the old one:
- APPROVED_EXCEPTIONS_FILE / EXCEPTION_AUDIT_LOG moved to _whitelist_locking;
the suite had appended 27 fixture entries to the live exception_audit.log.
- _OWNED_IDS_CACHE_FILE was never redirected at all: it is derived from
CONFIG_DIR at import time, so it captured the real path before the fixture
could patch it, and the suite overwrote the user's owned-games cache.
Both are now verified by md5summing ~/.config/steam_backlog_enforcer before
and after a full run, which is the only check that actually catches this --
the tests pass either way.
1347 tests, 100.00% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3