Dormant until earned_time.leisure_active(gaming day) -- the switch file
written by `leisure-watch go-live`. From that day:
- enforced `seconds` are billed by the shadow rule (lock pause, fail-closed,
once per wall clock); budget from plain resolve(); warnings and the cutoff
say "Leisure".
- at 0, beside the unchanged Steam path, _leisure_kill SIGTERMs (SIGKILL 30 s
later, per process) every focus/media pid in now.json except browsers,
every failclosed browser pid, and -- with now.json untrusted -- every
browser, media player and wsg-grabber reviewer. Root/system, lockers and
the enforcer's own chain are never signalled. Kills go to budget.jsonl.
- tests: an autouse fixture redirects both switch readers, so no test reads
/etc/leisure-watch/leisure_from; existing tests unchanged.
Re-pins earned-time to v0.15.0 (leisure_active).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
The enforcer now imports the Steam/launcher/counted-process scan from
leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops
its own copies (_playtime_procs, _counted_procs, _total_block_launchers and
enforcer's two scanners). Every counted-process read passes
config.CONFIG_FILE explicitly, as before.
Each tick also bills a shadow leisure budget beside the enforced one, from
the same incoming stamp and delta clamp, before accumulate: paused (held
gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game,
a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills
shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is
earned_time.resolve(leisure=True) from the same answers, held as a same-day
high-water mark. Not billed under a total block. Shadow verdict changes go
to budget.jsonl as shadow_change (the live game view skips them); /api/budget
and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff,
warnings and existing log records are unchanged.
Also fixes two tests that assumed earned_time < 0.14 internals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's
leftover was carried into the budget the cutoff used, and the 3-second
"Gaming budget" journal line omitted it. Unspent time is now dropped at
06:00 every day; load_state takes a legacy ledger's share back out of
today's high-water mark. The budget line logs only on change (was 25k
lines/day), fired warnings are logged, and budget.jsonl records carry
budget_seconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
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
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