Files
dotfiles/docs/superpowers/plans/2026-07-15-niri-gamemode.md
funman300 e0c12ff0c9 Plan: niri gamemode implementation (6 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:29:44 -07:00

564 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Niri Gamemode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A compositor-level gamemode for niri that inhibits idle, silences notifications, switches to the performance power profile (and bumps the fan), and hides waybar while a game runs — reverting cleanly when it ends — driven by both Feral gamemode hooks and a `Mod+G` toggle.
**Architecture:** A single reference-counted bash script, `scripts/gamemode-session.sh`, tracks two "sources" (`feral`, `manual`) via marker files in a runtime state dir. It applies effects only on the first active source (0→1) and reverts only on the last (1→0), snapshotting the pre-game baseline so revert restores exactly what was there. Feral's `gamemode.ini` custom hooks call `add feral`/`del feral`; a niri keybind calls `toggle` (flips `manual`).
**Tech Stack:** bash, `systemd-inhibit`, `makoctl`, `powerprofilesctl`, `fw-fanctrl`, niri KDL config, Feral gamemode (`gamemoded`).
## Global Constraints
- Script naming: `scripts/<name>.sh` symlinked to `~/.local/bin/<name>` (no `.sh` in the bin name). New script is `scripts/gamemode-session.sh``~/.local/bin/gamemode-session`. Name is `gamemode-session`, NOT `gamemode` (avoids Feral's `gamemoderun`/`gamemoded` namespace).
- Script must be crash-tolerant and never wedge the session: every effect step is individually guarded; a missing tool or a failed sub-step degrades to a no-op, and revert always runs every restore step.
- Effects apply ONLY on 0→1 active sources and revert ONLY on 1→0. Never re-snapshot baseline while already active.
- Power profile: only change it if the baseline profile differs from `performance`; on revert, only restore if we changed it (never clobber a deliberate `performance` choice).
- All new dotfiles are tracked in `~/Documents/dotfiles` (this repo) and deployed to their live locations by symlink/copy exactly as existing configs are.
- Commit after each task. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Verified environment facts: `makoctl mode -a/-r <mode>` and `systemd-inhibit --what=idle` are available; hypridle already honors systemd idle inhibitors (`ignore_systemd_inhibit = false`); `setsid`, `fw-fanctrl`, `powerprofilesctl`, `pgrep`, `pkill` all present.
---
### Task 1: Reference-counted state machine (`gamemode-session.sh` core)
Build the script's dispatch + source refcounting + baseline snapshot, with effects behind a `GAMEMODE_DRYRUN` seam so the state machine is testable without touching the live session. Effect *bodies* are stubbed in this task (dryrun-logged placeholders); Task 2 fills in the real commands.
**Files:**
- Create: `scripts/gamemode-session.sh`
- Test: `scripts/tests/gamemode-session.test.sh` (new throwaway test harness, committed alongside)
**Interfaces:**
- Produces: CLI `gamemode-session {add <source>|del <source>|toggle|status|reset}`.
- `add <src>`: touch `src.<src>` marker; on 0→1 snapshot baseline + `apply_effects`.
- `del <src>`: remove `src.<src>`; on 1→0 `revert_effects` + clear baseline/idle files.
- `toggle`: flip the `manual` source.
- `status`: `active` (exit 0) / `inactive` (exit 1).
- `reset`: force-revert + wipe state dir.
- Env seams (consumed by the test): `GAMEMODE_STATE_DIR` overrides the state dir; `GAMEMODE_DRYRUN=1` makes `run()` and effect functions log instead of act.
- Produces for later tasks: `apply_effects`/`revert_effects` and per-effect functions `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off` (Task 2 replaces their bodies).
- [ ] **Step 1: Write the failing test harness**
Create `scripts/tests/gamemode-session.test.sh`:
```bash
#!/bin/bash
# Drives gamemode-session in dryrun against a temp state dir and asserts the
# reference-counting state machine. No live effects run.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
SCRIPT="$HERE/gamemode-session.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GAMEMODE_STATE_DIR="$TMP/state"
export GAMEMODE_DRYRUN=1
fail=0
check() { # desc, actual, expected
if [ "$2" = "$3" ]; then printf 'ok - %s\n' "$1"
else printf 'FAIL - %s (got [%s] want [%s])\n' "$1" "$2" "$3"; fail=1; fi
}
active() { "$SCRIPT" status >/dev/null 2>&1 && echo active || echo inactive; }
check "starts inactive" "$(active)" "inactive"
"$SCRIPT" add feral >/dev/null 2>&1
check "feral activates" "$(active)" "active"
check "src.feral marker set" "$([ -e "$GAMEMODE_STATE_DIR/src.feral" ] && echo y)" "y"
check "baseline captured" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y)" "y"
"$SCRIPT" add manual >/dev/null 2>&1
check "manual keeps active" "$(active)" "active"
"$SCRIPT" del feral >/dev/null 2>&1
check "still active on 1 left" "$(active)" "active"
"$SCRIPT" del manual >/dev/null 2>&1
check "last del deactivates" "$(active)" "inactive"
check "baseline cleared" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y || echo n)" "n"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle on activates" "$(active)" "active"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle off deactivates" "$(active)" "inactive"
"$SCRIPT" reset >/dev/null 2>&1
check "reset leaves inactive" "$(active)" "inactive"
exit $fail
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `bash scripts/tests/gamemode-session.test.sh`
Expected: FAIL / non-zero (script `gamemode-session.sh` does not exist yet — `bash: .../gamemode-session.sh: No such file`).
- [ ] **Step 3: Write the core script**
Create `scripts/gamemode-session.sh`:
```bash
#!/bin/bash
# gamemode-session — compositor-level gamemode for niri.
# Reference-counted over two sources (feral, manual). Applies effects on the
# first active source (0->1) and reverts on the last (1->0).
# Spec: docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
set -u
STATE_DIR="${GAMEMODE_STATE_DIR:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode}"
FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
DRYRUN="${GAMEMODE_DRYRUN:-0}"
log() { printf 'gamemode: %s\n' "$*" >&2; }
run() { if [ "$DRYRUN" = 1 ]; then log "would: $*"; else "$@"; fi; }
is_active() {
[ -n "$(find "$STATE_DIR" -maxdepth 1 -name 'src.*' -print -quit 2>/dev/null)" ]
}
snapshot_baseline() {
mkdir -p "$STATE_DIR"
powerprofilesctl get 2>/dev/null > "$STATE_DIR/baseline.profile" || : > "$STATE_DIR/baseline.profile"
if pgrep -x waybar >/dev/null 2>&1; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.waybar"
if [ -f "$FAN_AUTO" ]; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.fanauto"
}
# --- effects (bodies filled in Task 2; dryrun-safe stubs for now) -------------
effect_idle_on() { run : "idle inhibit on"; }
effect_idle_off() { run : "idle inhibit off"; }
effect_dnd_on() { run : "dnd on"; }
effect_dnd_off() { run : "dnd off"; }
effect_perf_on() { run : "perf on"; }
effect_perf_off() { run : "perf off"; }
effect_waybar_on() { run : "waybar hide"; }
effect_waybar_off(){ run : "waybar restore"; }
apply_effects() { effect_idle_on; effect_dnd_on; effect_perf_on; effect_waybar_on; }
revert_effects() { effect_idle_off; effect_dnd_off; effect_perf_off; effect_waybar_off; }
# --- state machine ------------------------------------------------------------
cmd_add() {
local src="$1" was=0
is_active && was=1
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/src.$src"
if [ "$was" = 0 ]; then
snapshot_baseline
apply_effects
log "activated (source: $src)"
else
log "already active; +source: $src"
fi
}
cmd_del() {
local src="$1"
rm -f "$STATE_DIR/src.$src"
if ! is_active; then
revert_effects
rm -f "$STATE_DIR"/baseline.* "$STATE_DIR/idle.pid"
log "deactivated (last source: $src)"
else
log "still active; -source: $src"
fi
}
cmd_toggle() {
if [ -e "$STATE_DIR/src.manual" ]; then cmd_del manual; else cmd_add manual; fi
}
cmd_status() { if is_active; then echo active; exit 0; else echo inactive; exit 1; fi; }
cmd_reset() {
revert_effects 2>/dev/null || true
rm -rf "$STATE_DIR"
log "reset"
}
case "${1:-}" in
add) [ -n "${2:-}" ] || { log "add needs a source"; exit 2; }; cmd_add "$2" ;;
del) [ -n "${2:-}" ] || { log "del needs a source"; exit 2; }; cmd_del "$2" ;;
toggle) cmd_toggle ;;
status) cmd_status ;;
reset) cmd_reset ;;
*) log "usage: gamemode-session {add|del <source>|toggle|status|reset}"; exit 2 ;;
esac
```
Make it executable: `chmod +x scripts/gamemode-session.sh`.
- [ ] **Step 4: Run the test to verify it passes + shellcheck**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: `bash -n` silent; shellcheck clean (or only style-level SC2086-type notes — fix any warnings); all `ok -` lines, exit 0.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh scripts/tests/gamemode-session.test.sh
git commit -m "gamemode-session: reference-counted state machine core
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Real effect bodies (idle / DND / power+fan / waybar)
Replace the eight stubbed effect functions with the real commands. The Task 1 state-machine test still passes unchanged (it runs in dryrun, so effect bodies only log).
**Files:**
- Modify: `scripts/gamemode-session.sh` (the eight `effect_*` functions)
**Interfaces:**
- Consumes: `STATE_DIR`, `FAN_AUTO`, `DRYRUN`, `run()`, `log()`, and baseline files `baseline.profile`/`baseline.waybar`/`baseline.fanauto` written by `snapshot_baseline`.
- Produces: `idle.pid` file holding the systemd-inhibit PID while active.
- [ ] **Step 1: Replace the effect stubs**
In `scripts/gamemode-session.sh`, replace the entire `# --- effects` stub block with:
```bash
# --- effects ------------------------------------------------------------------
effect_idle_on() {
if [ "$DRYRUN" = 1 ]; then log "would: systemd-inhibit idle"; return; fi
command -v systemd-inhibit >/dev/null 2>&1 || { log "no systemd-inhibit"; return; }
systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block \
sleep infinity >/dev/null 2>&1 &
echo $! > "$STATE_DIR/idle.pid"
}
effect_idle_off() {
local pid; pid=$(cat "$STATE_DIR/idle.pid" 2>/dev/null || echo "")
[ -n "$pid" ] && run kill "$pid"
rm -f "$STATE_DIR/idle.pid"
}
effect_dnd_on() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -a do-not-disturb; }
effect_dnd_off() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -r do-not-disturb; }
# maps a power profile to the fw-fanctrl strategy (mirrors fan-profile.sh)
fan_for() { case "$1" in power-saver) echo lazy;; balanced) echo medium;; performance) echo agile;; *) return 1;; esac; }
effect_perf_on() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set performance
run pkill -RTMIN+8 waybar
fi
if [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
run fw-fanctrl use agile
fi
}
effect_perf_off() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set "$base"
run pkill -RTMIN+8 waybar
fi
if [ -n "$base" ] && [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
local strat; strat=$(fan_for "$base") && run fw-fanctrl use "$strat"
fi
}
effect_waybar_on() { # hide
pgrep -x waybar >/dev/null 2>&1 && run pkill -x waybar
}
effect_waybar_off() { # restore if it was running at entry
[ "$(cat "$STATE_DIR/baseline.waybar" 2>/dev/null)" = 1 ] || return 0
if [ "$DRYRUN" = 1 ]; then log "would: relaunch waybar"; else setsid waybar >/dev/null 2>&1 & fi
}
```
- [ ] **Step 2: Re-run the state-machine test (must still pass in dryrun)**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: shellcheck clean; all `ok -`, exit 0. (Effects only log under dryrun, so the state machine is unaffected.)
- [ ] **Step 3: Dryrun effect-ordering smoke (visual check of the "would:" log)**
Run: `GAMEMODE_STATE_DIR=$(mktemp -d) GAMEMODE_DRYRUN=1 bash scripts/gamemode-session.sh add feral 2>&1`
Expected output includes, in order: `would: systemd-inhibit idle`, `would: makoctl mode -a do-not-disturb`, a `would: powerprofilesctl set performance` (only if your current profile isn't already performance) and/or `would: fw-fanctrl use agile`, `would: pkill -x waybar`, then `activated (source: feral)`.
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh
git commit -m "gamemode-session: real effects (idle, DND, perf+fan, waybar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: mako do-not-disturb mode config
mako currently runs on defaults with no config file. Add a tracked config that folds in the current visible settings and adds the `do-not-disturb` mode so `effect_dnd_*` actually suppresses toasts.
**Files:**
- Create: `mako/config` (dotfiles) → deployed to `~/.config/mako/config`
**Interfaces:**
- Consumes: `makoctl mode -a/-r do-not-disturb` from Task 2.
- Produces: a `[mode=do-not-disturb]` block with `invisible=1`.
- [ ] **Step 1: Write the mako config**
Create `mako/config`:
```ini
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
```
- [ ] **Step 2: Deploy + reload mako**
```bash
mkdir -p ~/.config/mako
ln -sf ~/Documents/dotfiles/mako/config ~/.config/mako/config # match existing dotfile symlink style
makoctl reload
```
- [ ] **Step 3: Verify DND suppresses, then restores**
Run:
```bash
makoctl mode -a do-not-disturb; makoctl mode # expect list to include: do-not-disturb
notify-send "gamemode test" "should NOT appear" # no toast while DND active
makoctl mode -r do-not-disturb; makoctl mode # expect: default only
notify-send "gamemode test" "SHOULD appear" # toast appears
```
Expected: after `-a`, `makoctl mode` lists `do-not-disturb` and the first notification does not show; after `-r`, list is back to `default` and the second notification shows. (If `notify-send` is missing, install `libnotify` or skip — the mode-list assertion is the load-bearing check.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add mako/config
git commit -m "mako: config with do-not-disturb mode for gamemode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Feral gamemode hooks (`gamemode.ini`)
Wire Feral `gamemoded`'s custom start/end hooks to the script so gamemode-aware games trigger it automatically.
**Files:**
- Create: `gamemode.ini` (dotfiles) → deployed to `~/.config/gamemode.ini`
**Interfaces:**
- Consumes: `gamemode-session add feral` / `del feral` from Tasks 12.
- [ ] **Step 1: Write `gamemode.ini`**
Create `gamemode.ini`:
```ini
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
```
- [ ] **Step 2: Deploy**
```bash
ln -sf ~/Documents/dotfiles/gamemode.ini ~/.config/gamemode.ini
```
- [ ] **Step 3: Verify Feral reads the hooks**
Run: `gamemoderun gamemode-session status; echo "exit=$?"`
Expected: while `gamemoderun` holds the game session, the custom `start` hook has fired `add feral`, so `gamemode-session status` prints `active` (exit 0). When `gamemoderun` exits it fires `end``del feral`. Immediately after, `gamemode-session status` prints `inactive` (exit 1). (If it stays active, run `gamemode-session reset` and check `journalctl --user -u gamemoded` / the `gamemode.ini` path.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add gamemode.ini
git commit -m "gamemode.ini: Feral custom hooks drive gamemode-session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: niri config — keybind + remove dead VRR rules
Add the `Mod+G` toggle and remove the three no-op VRR rules the review found (panel has no VRR).
**Files:**
- Modify: `niri/config.kdl`
**Interfaces:**
- Consumes: `gamemode-session toggle` from Tasks 12.
- [ ] **Step 1: Add the keybind**
In `niri/config.kdl`, inside the `binds { ... }` block, near the other `spawn` binds (after the `Mod+Shift+B` line), add:
```
Mod+G { spawn "gamemode-session" "toggle"; }
```
- [ ] **Step 2: Remove the dead VRR rules**
In `niri/config.kdl`:
a) In the `steam_app_` window-rule, delete the line `variable-refresh-rate true` and change the comment to note the VRR removal. The rule becomes:
```
// Fullscreen games: no border, open fullscreen.
// (Re-add `variable-refresh-rate true` here if a VRR-capable external
// monitor is ever attached — the internal eDP-1 panel has no VRR.)
window-rule {
match app-id="steam_app_"
open-fullscreen true
border {
off
}
focus-ring {
off
}
}
```
b) Delete this entire rule (it wrongly targets every focused window and is a no-op here):
```
// Generic fullscreen request (any app asking to go fullscreen)
window-rule {
match is-focused=true
variable-refresh-rate true
}
```
- [ ] **Step 3: Validate niri config**
Run: `niri validate -c ~/.config/niri/config.kdl`
Expected: `Config is valid` (or niri's success message). Fix any parse error before continuing.
- [ ] **Step 4: Verify the bind live (optional if in a niri session)**
Run: `niri msg action do-nothing 2>/dev/null; grep -n "Mod+G" ~/.config/niri/config.kdl`
Expected: the `Mod+G` line is present. After niri reloads the config (niri hot-reloads on save), pressing `Mod+G` toggles gamemode; confirm with `gamemode-session status`.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add niri/config.kdl
git commit -m "niri: Mod+G gamemode toggle; drop dead VRR rules
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Wiring + live end-to-end smoke
Create the `~/.local/bin` symlink for the script (matching every other script), update `install.sh` if it enumerates them, then run one real activate/deactivate cycle and assert observable effects revert.
**Files:**
- Create: symlink `~/.local/bin/gamemode-session``~/Documents/dotfiles/scripts/gamemode-session.sh`
- Modify: `install.sh` (only if it lists script/config symlinks; otherwise no change)
**Interfaces:**
- Consumes: everything from Tasks 15.
- [ ] **Step 1: Create the bin symlink**
```bash
ln -sf ~/Documents/dotfiles/scripts/gamemode-session.sh ~/.local/bin/gamemode-session
gamemode-session status; echo "exit=$?" # expect: inactive / exit=1
```
- [ ] **Step 2: Wire install.sh if applicable**
Inspect `install.sh` for where existing scripts/configs get symlinked (e.g. a loop over `scripts/*.sh`, or explicit `ln -sf` lines). If found, add `gamemode-session`, `~/.config/gamemode.ini`, and `~/.config/mako/config` to the same mechanism. If `install.sh` does NOT enumerate these (the existing bin symlinks appear to be created manually), skip this step and note it in the commit.
Run: `grep -nE "ln -s|scripts/|\.local/bin|\.config" install.sh | head`
Expected: shows the deploy mechanism (or nothing → manual, skip).
- [ ] **Step 3: Live end-to-end — capture baseline**
```bash
echo "profile: $(powerprofilesctl get)"
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down"
makoctl mode
```
Record these; the cycle must return to exactly this state.
- [ ] **Step 4: Activate and assert effects applied**
```bash
gamemode-session add manual
sleep 1
gamemode-session status; echo "status_exit=$?" # active / 0
systemd-inhibit --list | grep -q gamemode && echo "idle: inhibited" # idle: inhibited
makoctl mode | grep -q do-not-disturb && echo "dnd: on" # dnd: on
echo "profile now: $(powerprofilesctl get)" # performance (unless baseline already performance)
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # down
```
Expected: status active; idle inhibited (a `gamemode` entry in the list); DND on; profile is `performance`; waybar down.
- [ ] **Step 5: Deactivate and assert full revert**
```bash
gamemode-session del manual
sleep 2
gamemode-session status; echo "status_exit=$?" # inactive / 1
systemd-inhibit --list | grep -q gamemode && echo "idle: STILL inhibited (BUG)" || echo "idle: released"
makoctl mode | grep -q do-not-disturb && echo "dnd: STILL on (BUG)" || echo "dnd: off"
echo "profile now: $(powerprofilesctl get)" # back to Step 3 value
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # up (matches Step 3)
```
Expected: status inactive; idle released; DND off; profile matches the Step 3 baseline; waybar back up. If any line reports a BUG or a mismatch, run `gamemode-session reset` to force-clean, then debug the corresponding `effect_*_off` before proceeding.
- [ ] **Step 6: Manual verification note (idle timeout)**
The 5-minute blank-suppression can't be asserted in seconds. Manually verify once: activate gamemode, leave the machine idle past 5 minutes (no input), confirm the screen does NOT blank; then `del`/toggle off and confirm hypridle resumes (screen blanks on the next idle window). Record the result. If it DOES blank while active, hypridle isn't honoring the systemd idle inhibitor on this build — fall back to a Wayland idle-inhibitor helper (documented follow-up), but the systemd path matches the current hypridle config intent.
- [ ] **Step 7: Commit**
```bash
cd ~/Documents/dotfiles
git add -A install.sh # only if changed; otherwise omit
git commit -m "gamemode: wire bin symlink + live smoke verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" # allow empty note if only the symlink (untracked) changed
```
(The `~/.local/bin/gamemode-session` symlink lives outside the repo — like the other script symlinks — so it isn't committed; note that it was created.)
---
## Self-Review
**Spec coverage:**
- Reference-counted two-source state machine → Task 1. ✓
- Idle inhibit / DND / performance+fan / hide waybar effects, each with exact-baseline revert → Task 2. ✓
- Baseline snapshot (profile, waybar, fanauto, idle.pid) → Task 1 (`snapshot_baseline`) + Task 2 (`idle.pid`). ✓
- "only change profile if baseline != performance; only restore if changed" → Task 2 `effect_perf_on/off`. ✓
- mako `[mode=do-not-disturb]` config → Task 3. ✓
- `gamemode.ini` Feral hooks → Task 4. ✓
- niri `Mod+G` bind + removal of 3 dead VRR rules (incl. `is-focused=true`) + breadcrumb comment → Task 5. ✓
- `reset` escape hatch → Task 1 dispatch + used in Task 6 debugging. ✓
- `status` subcommand for future waybar module → Task 1. ✓
- Script naming `gamemode-session`, `scripts/<name>.sh``~/.local/bin/<name>` convention → Global Constraints + Task 6. ✓
- Steam `gamemoderun %command%` usage → user-side (documented in spec Usage; not a code task), reflected in Task 4 verification via `gamemoderun`. ✓
**Placeholder scan:** No TBD/TODO; every code step shows full content; no "add error handling" hand-waves (guards are written out). ✓
**Type/name consistency:** `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off`, `apply_effects`, `revert_effects`, `snapshot_baseline`, `is_active`, `fan_for`, and the env seams `GAMEMODE_STATE_DIR`/`GAMEMODE_DRYRUN` are named identically across Tasks 1, 2, and 6. Baseline filenames `baseline.profile`/`baseline.waybar`/`baseline.fanauto` and `idle.pid`/`src.<source>` are consistent throughout. ✓