The enforcer runs as root under systemd and launches Steam via
`sudo -u <user> env DISPLAY=... XAUTHORITY=... DBUS_SESSION_BUS_ADDRESS=...`.
sudo uses env_reset, so that list is the child's entire environment - an
allowlist where every omission fails silently.
XDG_RUNTIME_DIR was missing. Without it Wine cannot reach
$XDG_RUNTIME_DIR/pulse/native, falls back from winepulse.drv to
winealsa.drv, and hands FMOD raw ALSA devices instead of the session sink.
Kingdom Come: Deliverance II died in its Bink startup video as a result.
Across 18 sessions the split was total: 9/9 runs logging "PulseAudio
Output" exited cleanly, 9/9 logging "HDA NVidia" crashed on the same line.
It presented as a corrupt install - the apparent cure was a 92GB reinstall -
but verify-integrity always passed. What actually fixed it was the Steam
restart the reinstall incidentally performed.
Extract the duplicated env construction into _desktop_env.desktop_env_args
and derive XDG_RUNTIME_DIR from the target uid, never from the ambient
environment: root's own value would point the child at /run/user/0.
PULSE_SERVER is deliberately not exported. pressure-vessel reads
XDG_RUNTIME_DIR to bind-mount the pulse socket into the Steam Linux Runtime
container, whereas a hardcoded unix:/run/user/<uid>/pulse/native names a
host path that need not resolve inside it.
Also gate the launch on the session existing. The enforcer is ordered only
After=network-online, while /run/user/<uid> is created by
user-runtime-dir@<uid>.service; on a measured boot the enforcer started 54ms
before that unit and won only by doing other work first. Launching in that
window would name a runtime dir that is not there and reintroduce the same
silent winealsa fallback for the whole session, so defer instead - the
enforce loop already retries every 3s, and DesktopSessionNotReadyError
subclasses SteamUnavailableError so existing callers degrade as before.
An ordering dependency would not be reliable here, since logind may start
user-runtime-dir outside the boot transaction.
Verified on the failing path rather than a dev shell: after restarting the
enforcer, the Steam it launched carried XDG_RUNTIME_DIR=/run/user/1000 and
KCD2 ran 15 minutes on "PulseAudio Output" with no crash artifacts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lock_enforcement_files() runs at the end of every enforce-loop iteration and
called _try_set_immutable() on both config files unconditionally, so the
daemon forked `chattr +i` twice per iteration whether or not the flag was
already set. Measured with bpftrace: 20 chattr execs per 30 seconds, ~56,000
per day, all of them re-applying a flag that was already there.
Read the flag first with FS_IOC_GETFLAGS, which is an ioctl and costs no
process, and only spawn chattr when the current state differs.
_immutable_flag_is() fails closed on purpose: an unsupported filesystem, a
refused ioctl, or an unreadable file all report False so the caller still
runs chattr. A wrong True would silently stop enforcing immutability, which
is much worse than a redundant fork.
Verified live, not just in tests: `chattr` execs over 30s went 20 -> 0 after
restarting the service, and clearing the flag by hand
(`sudo chattr -i ~/.config/steam_backlog_enforcer/config.json`) still got it
re-applied within ~10 seconds, so enforcement is unchanged.
Full suite: 1091 passed, coverage 100.00%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_remove_game_dirs deleted exactly one path at a time (the manifest's
current installdir, app-id gated by callers), but a real incident showed
that's not enough: an allowed game's install can end up split across
naming variants (spaced display name vs. installdir vs. a typo'd
variant), and any code path touching the wrong one risks destroying real
files. Add a fuzzy name-match safety net inside _remove_game_dirs itself
so it refuses to rmtree anything that plausibly names one of the
currently-allowed games, tolerating case/spacing/punctuation, spelled-out
numbers vs. digits vs. roman numerals, and minor typos -- independent of
whatever app-id gating the caller already does.
Extracted active_manual_picks/allowed_games/_pick_is_active out of
_actions.py into a new leaf module (_allowed_games.py) so game_install.py
can import allowed_games at the top level for this check without a
circular import (_actions.py already imports from game_install.py for
unrelated functions). _actions.py re-exports the two names other modules
still import from it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Config.max_manual_picks is the single source of truth for how many
games may be locked in at once; enforcement, CLI, and MCP tooling all
already read it dynamically. Updates docs referencing the old default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fast-vs-slow rush-ratio class, the STYLE_LABELS raw-key fallback, and the
non-positive estimated-total preset row were all unexercised, failing the
100% branch-coverage pre-push gate independently of the palette change in
the prior commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes every finding in the repo's own audit: spacing/radius scale, letter-
spacing compensation on <=13px text, --on-fill for filled segmented-control/
badge/button surfaces, 12-col .cards grid, mis-nested corner radius,
box-shadow -> outline on the dark-UI selection ring, and completes the
phantom --accent-bg token.
Verified: lint + 84/84 tests clean, build succeeds, re-verified identical
in an isolated worktree at HEAD, and pixel-checked against the real `serve`
command's output via headless-Chrome screenshot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
'./run.sh --abandon-pick <id>' printed the usage block and exited 1 with
no reason given: main() matched argv[1] against _ALL_COMMANDS exactly, so
a dashed spelling fell into the same catch-all as invoking with no args.
- _resolve_command() strips leading dashes from argv[1] (they carry no
meaning in that slot) and echoes a note when it normalises.
- _print_usage() now names the offending token and suggests the nearest
command via difflib, instead of silently reprinting usage.
Locks still receive the canonical command name, so a dashed spelling
cannot be used to dodge the manual-pick or total-gaming block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The enforcer allowed exactly one game: `current_app_id` was the single
"allowed" id threaded through the uninstall guard, the process killer, the
library hider and the auto-installer. Picking a second game manually meant
the first was uninstalled and hidden.
Manual picks are now a set, capped by `Config.max_manual_picks` (default 2).
Every active pick stays installed, visible and un-killed; the lock releases
only when all of them are finished or past their own 14-day deadline.
- `State.manual_picks` holds `{app_id, game_name, started_at}` entries. The
legacy single-slot `manual_pick_*` fields are still read on load, migrated
into the list and cleared, so a live lock survives the upgrade.
- `_actions.allowed_app_ids` / `allowed_games` are the single source of truth
for "may exist". `uninstall_other_games`, `hide_other_games` (incl. its
injected JS), `enforce_allowed_game` and `_guard_installed_games` now take
that set instead of one app id; every call site passes it.
- `pick-manual` joins `_MANUAL_LOCK_EXEMPT_COMMANDS` so a second game can be
added while the first holds the lock — the cap is what limits it, not the
lock check. Its post-pick cascade operates on the whole allowed set, so
adding a pick never tears down an earlier one.
- `abandon-pick <app_id>` drops only that pick; a survivor inherits the
assignment and keeps the lock. Grace and deadline are per pick.
- `cmd_done` is unchanged: it finishes `current_app_id` and auto-picks a
replacement, so two games stay queued. A finished pick leaves the active
set automatically via `finished_app_ids`.
Deployment note, learned the hard way during development: the daemon holds
the allowed set in code, so it MUST be restarted after upgrading. A
pre-upgrade daemon knows only `current_app_id` and uninstalled the other pick
as "unauthorized" within seconds of the second pick being made.
Verified live: legacy KCD2 lock migrated on load; a second pick (Vampire
Survivors) was accepted while the first was locked, with both kept by the
uninstall pass; a third was refused at the cap; abandoning one pick left the
other locked and handed it the assignment; the restarted daemon reports
"KEEPING allowed game" for both. 1060 tests pass at 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A manual pick had no escape hatch: once locked in, every command except
done/check/status/enforce/setup/serve exited 1 for 14 days or until 100%
achievements. A wrong pick meant two weeks stuck with it.
Adds a short mistake-correction window:
- MANUAL_GRACE_DAYS = 4 — within 4 days, 'abandon-pick <app_id>' backs out
of the pick. Outside it the command refuses and exits 1.
- The app_id must be passed explicitly and must match the active pick, so
an abandon cannot be triggered by muscle memory.
- 'abandon-pick' joins _MANUAL_LOCK_EXEMPT_COMMANDS — the pre-dispatch
lock check would otherwise block the only way out. It is dropped from
the advertised command list once the window closes.
- Abandoning clears the lock and the assignment, uninstalls the game, and
reuses the existing skipped_until cooldown for ABANDON_COOLDOWN_DAYS=30
so 'scan' does not hand it straight back.
- _actions.abandon_manual_pick is state-only (no uninstall cascade),
matching apply_manual_pick, so the gated MCP abandon_pick tool reuses it.
Also fixes a crash surfaced while picking a replacement game: the
SteamUnavailableError family (including SteamUpdateInProgressError, raised
when a game update defers a Steam restart) was only caught in the enforce
loop. cmd_hide, cmd_pick, cmd_pick_manual and both cmd_done reconciliation
points called hide_other_games bare, so an undrivable Steam aborted the
command with a traceback — after the pick had already been saved. All six
sites now go through the new stdout-free try_hide_other_games wrapper and
report "Library hiding: skipped (<reason>)" instead.
Verified live: lock message advertises the window, expired/wrong-id/no-arg
paths exit 1, and abandoning released the AoE2 lock, uninstalled it and
recorded the 30-day cooldown. 1030 tests pass at 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restarting Steam (steam -shutdown + relaunch for the CDP port) suspends any
in-flight game update. Repeatedly interrupting a large update — including
during the commit-to-disk phase — leaves a partially written install that
Steam's "verify integrity" still reports as OK, but the game's anti-tamper
detects and crashes on. This corrupted Age of Empires II DE (deterministic
0xc0000005 at AoE2DE_s.exe+0x29B9E3) once commit 00a08de made the enforcer's
CDP restart actually work and it began bouncing Steam mid-update.
Add steam_update_in_progress() in a new _steam_state leaf module (now the
single source of truth for STEAMAPPS_PATH, shared by game_install and
library_hider without an import cycle). It scans appmanifests for unfinished
download/stage bytes. ensure_steam_debug_port() now raises
SteamUpdateInProgressError (a SteamUnavailableError subclass, so the enforce
loop already skips + retries) and restart_steam() skips, when an update runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vw1sLrSjDksvH5zjU2RMYB
The systemd service runs as root with no User=, so _run_as_user() had no
SUDO_USER/USER to drop privileges to and launched Steam as bare root with
no DISPLAY/XAUTHORITY/DBUS session - its CDP port never opened. Add an
explicit STEAM_ENFORCER_DESKTOP_USER env var (set in the unit, substituted
by install.sh) so the enforcer always knows which desktop user owns the
Steam session, regardless of how it was launched.
Also fix a hardcoded _CDP_PORT=8080 colliding with an already-running
local service bound to 0.0.0.0:8080: requests to 127.0.0.1:8080 always
hit that service instead of steamwebhelper (which only bound
[::1]:8080), so CDP detection could never succeed even with the user
fix. Move to 9222, the conventional Chrome DevTools port.
Verified live: journalctl shows CDP ready and games hidden within ~7s
of a service restart, Steam running as the desktop user (not root).
A total gaming block on 2026-07-04 purged the Steam client (packages and
all data dirs, per total_block_purge_log.json) and it was never
reinstalled. The enforcer kept trying to drive it anyway: every start
called ensure_steam_debug_port() -> _launch_steam_with_debug(), waited
45s for a CDP port no process would ever open, raised, and exited 1 into
Restart=always. That ran to 1050+ restarts.
Each attempt also left a zombie behind: _run_as_user() launches
fire-and-forget via Popen and never reaps, so a launch that dies on exec
lingers as a dead process still named "steam". The focus-mode daemon
scans /proc for process names and read that zombie as a live Steam,
entering GAMING mode and killing the user's browsers every few seconds.
- steam_is_installed(): probe the real /usr/bin/steam. Deliberately not
shutil.which("steam") - a launcher wrapper on $PATH keeps `which`
truthy long after the package is gone, which is precisely how a
deleted Steam went on looking installed.
- SteamUnavailableError (a RuntimeError, so existing handlers still
catch it) raised on both "not installed" and CDP timeout, and caught
at the _enforce_hide_games boundary: no library to hide is not fatal,
the store block and install guard still work.
- Guard _enforce_loop_iteration and game_install._ensure_steam_running,
whose 15s sleep was the source of the observed flapping interval.
- do_enforce falls through to the idle loop rather than returning:
returning exits the process, which under Restart=always is the same
crash loop by another name.
- _reap_spawned() retires exited launches so no zombie named "steam"
outlives them.
Verified live: NRestarts 1050 -> 0, service active/running for 60s+ with
zero errors and zero steam zombies, logging "Steam is not installed -
nothing to enforce."
Also removed the orphaned /usr/local/bin/steam wrapper, a leftover of
the purge that pointed at the deleted binary. Its body was:
#!/bin/sh
exec /usr/bin/steam -cef-disable-gpu -cef-disable-gpu-compositing "$@"
Restore those flags if a Steam reinstall brings back GPU trouble.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rN2usTS3pYLJqeVg2ZpgR
Mirrors the CI-green safeguards rolled out repo-wide (see screen-locker):
- scripts/check_requirements_sync.py: pre-commit hook that fails if a
package import isn't declared in requirements.txt. On rollout it caught
a real latent gap — aiohttp is imported by hltb/protondb modules but was
only present transitively (via howlongtobeatpy); now declared explicitly.
- scripts/ci_mirror.sh: pre-push gate = requirements.txt-only venv
(hash-gated) running `pre-commit run --all-files` + `pytest`, replacing
the plain pytest-coverage pre-push hook. The web vitest pre-push hook is
left untouched.
Verified: mirror green (960 passed, 100% cov).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PMRRVVkeRyrSaaqueMZKG
The `except SteamAPIError` branch in _refresh_recently_played_completions
was only covered incidentally, by cmd_stats tests that make a real
network call which fails — reachable only under certain pytest-randomly
orderings, so CI's 100% coverage gate flaked (missing _stats.py 368-370).
Add a focused test that mocks SteamAPIClient.get_owned_games to raise
SteamAPIError, covering the branch reliably regardless of order or
network.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
CI on main was already red before this branch. Address the repo-wide
failures that `pre-commit run --all-files` and the test job surface:
- requirements.txt: add `mcp>=1.9` so the CI/dev env can import
steam_backlog_enforcer._mcp and collect test_mcp.py (mcp stays an
optional dep in pyproject for the light systemd/CLI path).
- _stats.py: hoist the SteamAPIError import to module scope (PLC0415)
and wrap an over-length f-string (E501).
- _web_dataset.py, docs/pick_game_manually_design.md: codespell typo
fixes (unparsable, achievements, aggressively, until) + trailing
newline.
- scripts/setup_mcp.sh: split declare/assign for SCRIPT_DIR/REPO_DIR
(shellcheck SC2155).
Tests remain 100% covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
Whitelist exceptions previously entered a 24h pending queue and were
promoted to approved by the enforce loop once the cooldown elapsed.
add_pending_exception now approves immediately, so the entire pending/
promote/cooldown mechanism is dead: drop WHITELIST_COOLDOWN_SECONDS,
PENDING_EXCEPTIONS_FILE, _load_pending/_save_pending,
promote_pending_exceptions, and list_pending_exceptions, plus the
enforce-loop promotion step and the CLI's now-empty pending display.
Usage text and docstrings updated to state exceptions are active
immediately. Tests for the removed machinery deleted; add-exception
tests assert immediate approval. Coverage remains 100%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
Expose the backlog enforcer's read surface and two gated write actions as
typed MCP tools (steam_backlog_enforcer._mcp) so Claude Code and its
subagents can query and drive the backlog without shelling out.
- _mcp.py: FastMCP stdio server. Read tools get_dataset/get_status/
get_stats/list_backlog; gated writes pick_manual/block_gaming (preview
unless confirm=True). Logs to stderr; never exposes Config/the API key.
- _actions.py: stdout-free, state-only cores shared by CLI and MCP
(apply_manual_pick, status_payload, is_manual_pick_locked). cmd_pick_manual
now delegates its state mutation here.
- .mcp.json + scripts/setup_mcp.sh: project-scoped registration via a
dedicated ~/.venvs/steam-backlog-mcp interpreter; mcp is an optional-dep,
kept out of the systemd/CLI system-python path.
- pyproject: fix setuptools flat-layout discovery and declare the previously
undeclared direct deps aiohttp + websockets, un-breaking the editable install.
- 100% coverage of the new modules; server verified end-to-end over stdio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v
guard-lib was folded into github.com/kuhyx/utils as a subdirectory;
update the install.sh detection/install path from $HOME/guard-lib to
$HOME/utils/guard-lib to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Total block now also deletes a curated allowlist of Steam/Proton paths
(~/.steam, ~/steam, ~/.local/share/Steam, ~/.steampath, ~/.steampid,
~/.config/steamtinkerlaunch, ~/.config/CSDSteamBuild) and uninstalls known
Proton helper packages (protondb-tags-git, protonhax-git, protontricks-git,
protonup-ng-git, protonup-qt), logging every removal to a purge log.
A curated allowlist rather than a recursive "anything named steam" sweep,
since the latter would also catch unrelated files (AUR build checkouts,
archives, other apps' own save data) that merely share the substring.
Live-verified against the real filesystem: all 7 paths and 5 packages were
actually removed, steam-backlog-enforcer's own directory/config untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFdVut4N4kMbJTUEEmfuQU
Adds `block-gaming <days>`: uninstalls Steam, kills/uninstalls known game
launchers, and blocks Steam + game-website domains (hosts + iptables) for a
fixed number of days with no in-app way to lift it early. Enforcement is
tamper-resistant via guard-lib's package-block (bind-mounted lock file) and
re-asserted every enforce tick.
Also migrates store_blocker.py's hosts-file locking from raw chattr/mount
calls to guard-lib's file-guard, using the new `sync` subcommand (not
`pacman-relock`) so our own legitimate edits aren't reverted as drift.
Fixes found during live verification:
- iptables never blocked real IPs because DNS was resolved after /etc/hosts
already redirected every blocked domain to 0.0.0.0 locally - reordered so
iptables resolves first.
- Game-website blocks only covered bare apex domains; sites that
301-redirect to www (e.g. newgrounds.com) sailed right through - added
automatic www. variant generation.
- Launchers (e.g. prismlauncher) were only killed, never uninstalled -
added best-effort pacman-package removal keyed off /proc/<pid>/exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
User can now pick any owned game by Steam app_id via `pick-manual <id>`.
The script resolves the game name, asks for YES confirmation, then locks
all other commands for 14 days or until the game is 100% complete.
Post-assignment steps (uninstall others, install, hide library) mirror
the automatic pick flow. Lock is checked before every command including
add-exception. Also fixes pre-existing test failures in hltb, stats,
and web_dataset modules and adds 100% coverage for all changed code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a React/TypeScript frontend (web/) with a Python stdlib HTTP server
backend. The UI mirrors the CLI `stats` command in the browser, with
real-time sliders for ProtonDB rating, HLTB confidence thresholds, daily
play time, per-game time cap, playtime mode, no-HLTB-data fallback, and a
target-date planner. A parity badge confirms the client-side totals
reproduce the CLI defaults exactly (786 / 67031.1h / 143017.2h / 238447.9h).
Python side:
- `_web_dataset.py`: offline projection of HLTB/ProtonDB/snapshot caches
into a compact, secret-free JSON payload; 100% branch coverage
- `_web_server.py`: zero-dependency stdlib HTTP server serving the built
Vite bundle and the /api/dataset endpoint; 100% branch coverage
- `main.py`: new `serve` command wiring
Frontend (Vitest + RTL, 100% branch coverage enforced):
- TypeScript port of ProtonDB compound rating rule with full parity
- Pure client-side filtering via estimate.ts (no server round-trips)
- SVG completion timeline chart, sortable/searchable game table
- Steam dark theme; responsive layout
Pre-commit: adds `vitest-coverage` hook at pre-push stage requiring 100%
branch coverage on the React codebase.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move websockets after types-requests (alphabetical order for requirements-txt-fixer)
- Add test for save_hltb_cache with all extras maps populated (covers _hltb_types.py:216-218)
- Add test for _fetch_batch_confidence_only with truthy hp_key and pre-populated count_comp (covers hltb.py:82-83, 90->93)
- Add test for count_comp_detail > 0 path in _apply_detail_to_extras (covers _hltb_detail.py:254)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract count_comp from detail page in _apply_detail_to_extras so the
all-playstyles completion count is populated even when the search API
returns 0 (Mini Ghost: 0 → 69, now passes confidence thresholds)
- Fix _refresh_candidate_confidence to trigger re-fetch when count_comp==0
even if comp_100_count>0 (was silently skipping stale partial entries)
- Filter colon-stripped fallback candidates (e.g. "Vox Populi" from
"Vox Populi: Poland 2023") to full-edition or exact matches only,
preventing cross-franchise false positives
- Demote "All N ProtonDB ratings found in cache" log to DEBUG to remove
per-game noise from the scan output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four bugs fixed:
- HLTB search returned 0 results for ~87 games with special chars (™, ®, &,
standalone -, (Legacy), RHCP, etc.) — add _sanitize_search_name() and
extend _build_search_variants() with Steam-suffix and edition stripping
- fetch_hltb_detail_missing returned immediately because `app_id not in rush`
was always False (all keys present with -1) — fix to `rush.get(id,-1) <= 0`
- save_hltb_cache overwrote rush/leisure on confidence-only partial saves —
now reads existing cache and preserves data when extras dicts are empty
- _filter_qualifying_games excluded 57 games with stale snapshot hours (-1)
even though HLTB hours cache had valid data — add cache fallback
Result: stats shows Rush 64,670h / Leisure 136,807h / Worst 228,594h for
all 785 qualifying games with full rush+leisure detail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the auto-reassign-to-shorter-game logic (which fired while the
current game was still in progress) with a strict workflow:
1. Check if assigned game is finished.
2. If not, do nothing.
3. If yes, pick the next shortest game and prompt the user.
4. If the user skips, ignore that game for 7 days and pick the next
shortest candidate.
Changes:
- State: add skipped_until + skip_for_days + active_skipped_ids.
- scanning.pick_next_game: optional on_select callback drives a
sequential picker that filters skipped IDs; legacy cmd_pick flow
preserved when on_select is None.
- _cmd_done._finalize_completion: pick + prompt via on_select.
- _cmd_done: remove _try_reassign_shorter_game and helpers
(_apply_cached_confidence_to_games, _should_reassign_candidate,
_echo_reassign_decision, _evaluate_reassign_iteration) plus call
site in cmd_done.
- Tests: drop obsolete _try_reassign_shorter_game suite; add
TestPromptKeepOrSkip, TestPickNextGameSequential, and State
skipped_until tests.
- library_hider.py: add safeHide(ids) JS helper that binary-bisects on failure
to skip problematic DLC/tool IDs without blocking the entire hide pass
- library_hider.py: increase CDP timeout 30s -> 120s; extract richer CDP error
details from exceptionDetails/exception.description
- _hltb_detail.py: rewrite _extract_base_leisure_hours() to pick the maximum
(slowest) time across all platform comp_high values and *_h fields; add
_platform_comp_high_candidates() helper
- Remove skip_app_ids from user-editable Config; callers updated
- Split PROTECTED_APP_IDS: only Steam infra/Proton IDs remain; game
IDs moved to a new time-locked exception system
- Add _whitelist.py: 24-hour cooldown on new exceptions, entropy-
checked justification (>= 5 words), append-only audit log,
chattr +i immutability on enforcement-critical config files
- Add is_protected_app() in game_install.py; used everywhere
instead of direct PROTECTED_APP_IDS membership checks
- Add 'add-exception' CLI command (cmd_add_exception in main.py)
- Call promote_pending_exceptions() and lock_enforcement_files()
in each _enforce_loop_iteration
- 590 tests, 100% branch coverage on all steam_backlog_enforcer modules
- Add .worktrees to .gitignore
- linux_configuration/tests: update script paths after periodic_background/
reorganisation (hosts_file_monitor, makepkg_capped, music_parallelism,
shutdown_timer_monitor, usage_monitoring_installer_efficiency)
- test_i3blocks_efficiency.sh: remove checks for HEARTBEAT_INTERVAL_S and
WARP_POLL_INTERVAL_S constants that no longer exist
- test_pacman_wrapper_security.sh: remove tests 20-21 (builtin time helpers /
external date calls) that are no longer applicable; update path
- generate_hosts_file.sh: add sed unblock rules for delio.com.pl and
loverslab.com to stay consistent with install.sh whitelist
- steam_backlog_enforcer/scanning.py: remove unplayable_reason arg from
logger.info call (too many format args); drop matching test assertion
- steam_backlog_enforcer/tests/test_protondb.py: add
test_unplayable_reason_no_trending_tier to restore 100% branch coverage
on protondb.py line 97 (was previously covered indirectly)
- meta/.pre-commit-config.yaml: move pytest-coverage hook to pre-commit stage
- scripts/pytest_changed_packages.py: single batched pytest -n auto invocation
with one --cov flag per affected python_pkg subpackage, wrapped in
systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 when available
- python_pkg/steam_backlog_enforcer/tests/conftest.py: new autouse
_no_real_sleep fixture patches time.sleep across game_install /
library_hider / steam_api / _enforce_loop. Removes 3x 15s real sleeps
in TestFinalizeCompletion that fired through _ensure_steam_running
steam_backlog_enforcer test wall time: 33.97s -> 5.61s (xdist, no-cov)
5-package batched run: 732 tests in 1.37s @ 668% CPU
Coverage stays at 100% on all affected packages.
Evidence: docs/superpowers/evidence/pre-commit-pytest-batch-2026-05-14.json
Adds 1410710, 10500, 813780, 489830 to PROTECTED_APP_IDS so the enforcer
will not uninstall them. Existing tests patch the set, so test outcomes
are unaffected.