Commit Graph

91 Commits

Author SHA1 Message Date
dd480f3208 Bring _stats.py and scanning.py under the 250-line cap
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
2026-08-24 16:00:54 +02:00
965abcaeaa 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:54:34 +02:00
737b49246c Allow abandoning a manual pick at any time; cap picks at 2
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
2026-08-24 15:41:32 +02:00
0f25fba476 Bring the HLTB modules under the 250-line cap
_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
2026-08-24 15:23:13 +02:00
8f010cfe46 Point the iptables tests at the module that now holds the code
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
2026-08-24 15:07:58 +02:00
aecfd1a3a0 Split store_blocker along its one-way call boundaries
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
2026-08-24 15:06:15 +02:00
d8ebbf978b Split _actions, _whitelist, _cmd_done and hltb under the cap
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
2026-08-24 14:55:57 +02:00
07e6e15b6e Split steam_api and _scanning_confidence under the cap
steam_api becomes a thin re-export over _steam_models (dataclasses) and
_steam_api_client (HTTP). Inverting it that way rather than moving the
client out and importing it back matters: _steam_api_client needs GameInfo,
so a re-export from the old module would have been a cycle. 47 call sites
import from steam_api and none of them change.

_scanning_confidence sheds the poll backfill/reporting pair into
_polls_reporting, and re-exports both.

Test patch targets follow the code: the tests for _report_poll_confidence
now patch _polls_reporting._echo, because that is where the name is bound.
Patching the re-exporting module intercepts nothing, and the test then
fails on an empty output list rather than an import error, which is why
these are worth being careful about.

1347 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 14:25:43 +02:00
9a2dd4fb73 Bring every test file under the 250-line cap
The pre-push ci-mirror runs the file-length gate over the whole tree, not
the diff, so ~30 files left it red and no push could land. This clears the
test half: 18 modules split into 60, plus the docstrings pylint wants on
every class and test method.

Splits are by responsibility, never mechanical partN names -- files are
named for the first class or test they contain (test_stats_sum_hours,
test_store_blocker_unblock_store, ...). Where a file was a single oversized
class, its test methods are partitioned into sibling classes instead, with
fixtures and helper classes copied into every part: a method-level split
that drops an autouse fixture fails at runtime, not at import, so it is easy
to miss.

test_polls_tracking_part2 keeps its name only because it still holds one
indivisible class; the rest of the partN files are gone.

1347 tests pass, unchanged in count and name from before the split -- the
splitter was checked by diffing test-function names, since a test silently
lost in a partition looks identical to one that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 14:16:08 +02:00
919ecdfd62 Re-arm the no-library warning before install_game's early return
The one-shot latch that stops the "Steam library not initialised" warning
repeating every 3s was only cleared *after* the is_game_installed() early
return. So once Steam was signed in and a game was installed, the latch
stayed set: if the library later vanished the warning would never fire
again, and the outage would be silent.

Clear it at the top instead, before any return. Found by writing the test
for the latch's debug branch, which was the last uncovered code in the
package.

Coverage is now 100.00% (1347 tests) under .ci-mirror-venv, which is the
gate CI actually runs -- system python3 has mcp 2.0 against a pinned mcp<2,
so test_mcp*.py cannot collect there and coverage reads low.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 14:04:54 +02:00
21fe2f7742 Stop the enforcer launching Steam and notifications as root
The enforce service runs as root (no User=) but sets DISPLAY=:0, so any GUI
it starts lands on the desktop session. Two resolvers disagreed about who to
drop privileges to:

  library_hider._resolve_desktop_user()  read STEAM_ENFORCER_DESKTOP_USER
  game_install._get_real_user()          read only SUDO_USER or USER

systemd sets neither of the latter, so _get_real_user() returned None under
the service and every `geteuid() == 0 and real_user and ...` guard fell
through to running as root. Steam answered with a zenity modal ("Cannot run
as root user", /usr/lib/steam/steam:261) on screen.

Confirmed from the live process tree: root `bash /usr/lib/steam/steam
-cef-disable-gpu ... steam://install/618510` with a root zenity child. It
never reached the journal, because that text goes to zenity and _run_as_user
sends child output to DEVNULL.

- _desktop_env: add resolve_desktop_user/desktop_uid/desktop_user_cmd. Only
  the argv builder is shared, never the spawn call: tests/_no_subprocess.py
  patches subprocess per module by name, so a spawn site in an unpatched
  module could fire a real `sudo -u kuhy steam`.
- game_install._get_real_user: delegate to the shared resolver. This alone
  also fixes the root `steam -silent` in _ensure_steam_running and the
  no-op os.chown on new appmanifests.
- _trigger_steam_install / enforcer.send_notification: drop privileges.
  send_notification had no DBUS_SESSION_BUS_ADDRESS, so it silently reached
  nobody for all six of its callers.
- _run_as_user: fail closed. Running unchanged as root when no desktop user
  resolves is what put the modal on screen; refuse and log instead.

Also stop the retry storm behind it. steamapps does not exist (Steam has
never completed sign-in), so is_game_installed() was always False and the
loop re-triggered every allowed game every pass: 1656 attempts in 30
minutes, service RSS 1.5 GB (peak 4.3 GB), a FileNotFoundError traceback
each time. Guard install_game and _reinstall_missing_allowed on
steam_library_ready() -- a missing library means no install can succeed by
any route, so this is a correctness check rather than a tuned retry limit.

The module splits here are forced by the pre-commit 250-line gate, which
blocks any commit touching an over-cap file: game_install, library_hider and
_enforce_loop were already over before this change. They are split by
responsibility (_cdp transport, _steam_launch lifecycle, _steam_process
spawning, _steam_errors, _steam_client, _game_names, game_uninstall,
_enforce_steps, _owned_apps_cache, _echo, _protected_apps). game_install and
library_hider re-export their old public names, so the 15 modules that
import from them are untouched by this commit.

Verified as real root under the unit's environment: argv is now
`sudo -u kuhy env DISPLAY=:0 ... xdg-open steam://install/...` for both the
installer and notify-send; three enforce passes spawn no process and log
once; _run_as_user refuses with no desktop user. After restart: 0 retries,
0 tracebacks, 0 root-owned files under ~/.local/share/Steam, RSS 342 MB.

1311 tests pass. _desktop_env and _steam_state at 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 13:55:44 +02:00
2ad87524e4 Stop a non-interactive install aborting on the service prompt
The systemd-service question was a bare `read -rp`. Under `set -euo pipefail`
a read that hits EOF returns non-zero, so every caller without a TTY --
install_core_system.sh, CI, `vm run` -- aborted the installer with exit 1 at
that line, after the Python deps were already in place. That made the module
permanently un-installable from its documented install path.

It now defaults to "n" when stdin is not a terminal and says how to add the
service later; STEAM_ENFORCER_INSTALL_SERVICE=y opts in non-interactively.
The interactive path is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
2026-08-24 13:04:00 +02:00
740ec43767 Add a continuation prompt for the 250-line cap refactor
Captures the state at b5b4b35 (39 files over the cap, 1324 tests at 100%,
pylint 8.69) plus the decisions already settled, so the next session does
not re-litigate scope or repeat the two approaches that failed here:
a mechanical line-moving extractor (349 F821s, reverted) and find/replace
on patch targets that misses patch.object and multi-line forms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 20:55:24 +02:00
b5b4b35c07 Split total-block, playtime and MCP by responsibility
Each of these was one file doing several jobs, so they are split along
what they actually do rather than at arbitrary line offsets:

- _total_block: the lock file and the status derived from it stay in
  _total_block; making Steam *not installed* moves to _total_block_purge,
  making it *unreachable* to _total_block_hosts / _total_block_iptables.
  The generic pacman verbs (owner, installed, remove) become _pacman,
  since nothing about them is total-block-specific. Domain and process
  name tables become data modules.
- _playtime: budget state and persistence (_playtime_state), pure
  transitions (_playtime_budget), cmdline process detection
  (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff),
  desktop notifications (_playtime_notify), process-tree walking and
  killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and
  the shared privileged-command helper (_playtime_run).
- _mcp: read-only tools (_mcp_query) split from mutating ones
  (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is
  answerable from one file. _mcp keeps the server object and entry point.

No re-export shims: callers import from the module that defines the name.
That keeps a stale patch target loud (AttributeError) instead of letting
it pass while intercepting nothing.

`ruff check --select F821,F401` is run after every extraction -- an earlier
mechanical attempt moved functions without the module-level names they
referenced, which imported fine and only failed when called (349 errors),
so it was reverted and redone by hand.

The autouse fixtures in conftest are now imported by name and held by
__all__; ruff --unsafe-fixes had deleted them as unused, since pytest
resolves fixtures by name.

1324 tests pass at 100.00% coverage in the clean venv; `main status` is
byte-identical to a worktree at HEAD. 49 -> 39 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 20:52:08 +02:00
45ff4eb7c2 Split main.py (1076 lines) into a main/ package
main.py became main/ with __main__.py, so the invocation string baked into
run.sh and the systemd unit (`python -m steam_backlog_enforcer.main`) stays
byte-identical and nothing needs redeploying. Commands are grouped into
sibling modules (status, install, picks, abandon, misc), with _shared.py as
a leaf holding the lock helpers and constants both need, and _registry.py
holding the command tables. main/__init__.py keeps every public and private
name importable, so `from steam_backlog_enforcer.main import X` is unchanged.

The subtle part is patch targets. The test suite builds them as
f"{PKG}.{name}", so a grep for the literal module path found nothing -- but
once a command moves to a submodule it resolves its dependencies from that
module's globals, and patching the main package's re-export silently stops
biting. Measured which of the 36 patched symbols moved: 8 could have passed
vacuously. Rather than repair PKG per class, the five test_main*.py files
(all cap violators anyway) were regrouped to mirror the production modules,
so each file has exactly one correct PKG. Verified by mutation: breaking a
line in status/misc/picks/_shared each makes the corresponding tests fail.

test_web_server.py patched main_mod.serve, which no longer intercepts
cmd_serve; repointed at main.misc and confirmed it fails when the call is
removed. Added tests for the __main__ shim, which coverage would otherwise
report at 0% under fail_under = 100.

Shared test builders live in tests/_main_helpers.py, reachable because the
Phase 0 name-tests-test exclude allows tests/_*.py.

1290 tests pass; every main/ module reports 100% coverage; `main`, `status`,
`bogus` and `--status` produce byte-identical output and exit codes to
pre-refactor. 55 -> 49 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 19:56:34 +02:00
10c33690b9 Add the 250-line file-length gate as a pre-commit hook
Vendors ~/utils/file_length into scripts/file_length so the gate is
reachable in CI: pre-commit's `entry:` is not shell-expanded and GitHub
runners have no ~/utils, so an external reference would pass locally and
silently skip on push. The existing pre-commit/action all-files job picks
the hook up with no workflow change.

The vendored copy is excluded from ruff/ruff-format/mypy/pylint and must
stay byte-identical to upstream -- ruff --unsafe-fixes otherwise rewrites
its print() calls to `pass` (T201/F841), which leaves the gate exiting 1
while reporting nothing.

Also:
- name-tests-test now exempts tests/_*.py, so the 35 test-file splits have
  a landing zone for shared helpers (it rejects any non-test_*.py module
  under tests/).
- pylint max-module-lines 1000 -> 250, so both limits agree.

Note: .git/hooks/pre-commit was never installed in this repo (only
pre-push), so no commit-stage hook had ever run. Installed via
`pre-commit install`.

55 files are currently over the cap; they are split in following commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 19:27:09 +02:00
45178114cb Add a session prompt for taking pylint to 10.00
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
Measured numbers, the per-category breakdown, the gate commands verified to
run as written, and the traps that cost time when the same job was done in
~/utils/crdt-sync (8.58 -> 10.00 on 2026-08-21).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:39:00 +02:00
1091975dfa Update refactor_claude_todo.md counts after gate exemption fixes
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
Git-ignored files, vendored agent-skill bundles and the CI checkout dir are
now exempt, since no commit could ever fix them. Repo counts and the ranked
violation lists are regenerated accordingly; the global baseline drops from
657 to 597.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 16:03:01 +02:00
e92feade09 Add refactor_claude_todo.md: 250-line file-length cap
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
A ready-to-use prompt describing the goal (every file at most 250 lines,
code and prose alike), this repo's actual violations ranked by Fowler ROI
(lines x commits/yr), how to split each kind, and the requirement to wire in
the shared pre-commit gate so files stay small permanently.

Generated from a measured survey of every repo; the gate itself lives in
~/utils (file_length/ + scripts/check_file_length.sh).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:25:42 +02:00
dec3c9b58b Pin mcp below 2.0 so CI can import mcp.server.fastmcp again
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
mcp 2.0.0 removed the mcp.server.fastmcp module that _mcp.py imports, so the
unbounded `mcp>=1.9` let a fresh CI runner resolve to 2.0.0 and fail
collection with ModuleNotFoundError.

This did not reproduce locally: this machine has mcp 1.28.1 installed, and
ci_mirror.sh caches its clean venv on the hash of requirements.txt, so it
reused an environment resolved back when >=1.9 still meant 1.x. The mirror
validates the resolution you already have, not the one a new runner gets.

Verified against the CI interpreter: a fresh python 3.11 venv built from the
pinned requirements resolves mcp 1.29.0, imports FastMCP, and runs the full
suite -- 1322 passed at 100.00% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:07:09 +02:00
0c05d71008 Add a daily gaming-time budget with a binary-level cutoff
Accumulates time spent in Steam games and other launchers, and once the
daily budget (default 8h) is spent, shuts Steam down, kills the games, and
makes them unlaunchable until the next 06:00.

The gaming day starts at 06:00 local rather than midnight so a late session
counts against the day it began in; rollover is checked lazily against a
stored day key, so it survives suspend, reboot and daemon restarts with no
timer of its own. Elapsed time comes from wall-clock deltas clamped to two
tick intervals, which keeps suspends, missed ticks and NTP steps honest.

The block is a read-only bind mount of a refusal stub over all four launcher
entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it
alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount
mutates no bytes, umount restores exactly, and a reboot clears it
unconditionally; chmod/rename would leave Steam broken if the daemon died
mid-block. /proc/self/mountinfo is the authority on what is masked, never the
saved state, which is what makes a stale block release even if the state file
was deleted. The mount is deferred one tick past the cutoff so
`steam -shutdown` still works and games close cleanly.

Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3
script, so the kernel records its comm as "python3" and the existing scan
never sees it. The kill set is wider than the budget predicate, walking down
from each launcher, because a Lutris-launched Wine game carries no SteamAppId.

State is chattr +i'd -- ~/.config is user-writable, so root ownership alone
would not stop `rm` from resetting the counter -- and a missing state file
while mounts are live fails closed rather than lifting the block.

Also fixes a pre-existing bug: do_enforce returned when no game was assigned,
exiting the process. Since the budget is accounted for from inside that loop,
it silently stopped enforcing whenever a game was finished but not rescanned.

Verified on the desktop with a real game: AoE2 counted 18 processes, tripped
at the 60s demo budget, all processes killed, all four paths masked and
refusing, then gaming-unblock restored them byte-identical with
`pacman -Qkk steam lutris` reporting 0 altered files.

1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:00:08 +02:00
bfdd69453e Pass XDG_RUNTIME_DIR when launching Steam as the desktop user
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
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>
2026-07-26 14:39:38 +02:00
243e11bc00 perf(whitelist): only shell out to chattr when the immutable flag differs
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
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>
2026-07-25 20:15:12 +02:00
e5d0627a30 fix(game_install): refuse to delete folders matching an allowed game's name
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
_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>
2026-07-25 15:50:29 +02:00
cdc453bdf4 feat(enforcer): raise manual pick cap from 2 to 3 concurrent games
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
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>
2026-07-23 18:05:29 +02:00
ba412ca04f test(web): cover the 3 pre-existing branch-coverage gaps in SummaryCards
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
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>
2026-07-22 17:40:25 +02:00
6202db54a0 web: warm-tint the unified-design-system palette, fix DESIGN_AUDIT_TODO violations
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>
2026-07-22 17:35:40 +02:00
94feb77046 Add a design-rules audit against the new safe-design-rules checklist
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
Report-only findings for a future cleanup pass; no code changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:25:20 +02:00
55e535c935 Add change explanation: cli-dashed-subcommands
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:33:14 +02:00
a5dd56941d fix(enforcer): accept flag-style subcommands, explain unknown ones
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
'./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>
2026-07-19 18:21:30 +02:00
a3d7641193 Add change explanation: manual-pick-grace-and-multi-pick
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:42:00 +02:00
05e070ca27 feat(enforcer): allow two concurrent manual picks
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>
2026-07-19 17:28:23 +02:00
eb1472a433 feat(enforcer): add 4-day grace period to undo a manual pick
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>
2026-07-19 17:04:08 +02:00
be2b7c7b93 Add change explanation: defer-steam-restart-during-update
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vw1sLrSjDksvH5zjU2RMYB
2026-07-18 16:15:54 +02:00
dbc3242c07 fix(enforcer): defer Steam restart while a game update is in progress
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
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
2026-07-18 15:59:47 +02:00
6e6f3eba61 chore: expanded protected app ids for more steam tooling and proton versions
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
2026-07-18 09:29:53 +02:00
bfad861970 chore: expanded protected app ids for more steam tooling and proton versions 2026-07-18 09:29:33 +02:00
7261f09973 Add change explanation: steam-cdp-restart-fix
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
Interactive walkthrough of commit 00a08de - the root/no-display
privilege-drop bug and the CDP port collision with Open WebUI.
2026-07-18 06:58:01 +02:00
00a08de076 fix(enforcer): Steam CDP restart never worked (root+no-display, port collision)
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
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).
2026-07-18 06:49:10 +02:00
d54df3a1ad fix(enforcer): idle when Steam is absent instead of crash-looping
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
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
2026-07-17 13:53:24 +02:00
e9f60e8ee8 Add CI-mirror pre-push gate + requirements-sync check
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
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
2026-07-11 17:47:14 +02:00
27c637c009 Deterministically cover the SteamAPIError branch in stats refresh
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
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
2026-07-10 23:10:44 +02:00
0c48ed022c Fix pre-existing CI failures from the MCP-server commit
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
2026-07-10 23:04:07 +02:00
de76c6541e Remove whitelist exception cooldown; approve immediately
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
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
2026-07-10 22:57:39 +02:00
26c4771192 Add MCP server exposing the enforcer to Claude Code
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
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
2026-07-10 21:05:17 +02:00
c4e9e4dfc3 Point guard-lib at the consolidated utils monorepo
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
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>
2026-07-10 14:01:04 +02:00
95bd0b93bd feat: block-gaming purges Steam/Proton filesystem remnants + helper packages
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
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
2026-07-04 17:25:08 +02:00
7554b58ab7 feat: add block-gaming command (Stage 4) + guard-lib migration cleanup
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
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
2026-07-04 11:45:54 +02:00
7ac07c4b7a feat: add pick-manual command with 2-week enforcement lock
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>
2026-05-30 17:15:37 +02:00
41deb90324 feat: add interactive web UI for backlog completion planning
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>
2026-05-29 18:35:45 +02:00