diff options
21 files changed, 6424 insertions, 1740 deletions
diff --git a/archive/task-archive.org b/archive/task-archive.org index 2925180..3811cc6 100644 --- a/archive/task-archive.org +++ b/archive/task-archive.org @@ -1627,3 +1627,1306 @@ Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — ev ** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles: CLOSED: [2026-07-18 Sat] Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface. +** DONE [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs: +CLOSED: [2026-07-19 Sun] +Recovered. Velox boots linux-lts 6.18.38 and is back on the tailnet (up 1d+, /boot holds initramfs-linux-lts.img). The pre-pacman ZFS snapshot rollback restored the kernel from the ZBM recovery shell. +Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it. + +Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot. + +Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it. + +Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R): +#+begin_src sh +# 1. pool writable + key loaded +zpool get readonly zroot +zfs get -H -o value keystatus zroot/ROOT/default +# if readonly=on: zpool export zroot && zpool import -f -N zroot +# if keystatus=unavailable: zfs load-key zroot + +# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time) +zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman + +# 3. see current /boot state (read-only mount) +umount /mnt/be 2>/dev/null; mkdir -p /mnt/be +mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be +ls -la /mnt/be/boot + +# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name +umount /mnt/be 2>/dev/null +zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R + +# 5. VERIFY before reboot — remount RO, confirm the kernel is back +mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be +ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img +umount /mnt/be + +# 6. only once /boot shows a kernel: +zpool export zroot && reboot +#+end_src +Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-velox-boot-failure-handoff.org=; ZBM photo: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-PXL_20260715_043758976.jpg= (local on ratio; inbox is gitignored). +** DONE [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick: +CLOSED: [2026-07-19 Sun] +Shipped dotfiles 9dfe082: date-only ring (ordinal/full/longdate), on-scroll rewired, layout guard flipped. UTC/time stay on the time module. +Date and time are separate fixed-position controls. The time display cycles its +own formats, including UTC; the date/calendar control cycles date-only formats +and never displays a second time. Implement the dedicated format rings, +tooltip behavior, and tests together in the dotfiles Waybar configuration. +Reference material for the compact clock/chronograph treatment is filed in +[[file:working/clock-display-references/][working/clock-display-references/]]. + +*** 2026-07-19 Sun @ 04:36:26 -0500 Folded clock-panel interaction direction +The clock-panel handoff settled the prior open question: UTC belongs only to +the time ring, while the date ring is date-only. The existing task is therefore +a focused follow-up, not a two-line restoration of the old combined ring. +** DONE [#C] Notification sound loudness :chore:audio:quick:solo: +CLOSED: [2026-07-19 Sun] +Shipped dotfiles 808ca23: NOTIFY_VOLUME default 65536->39322 (0.6 gain) in both notify copies. +Reduce notification-sound playback loudness by 40% (0.6 gain, approximately +-4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding +the normalized sound files; verify each notification type still plays clearly. +** DONE [#C] Show the active wired interface in the Waybar network module :feature:waybar:network: +CLOSED: [2026-07-19 Sun] +Shipped dotfiles 22867f9: select_device prefers connected wifi -> connected ethernet -> wifi fallback, so a live cable shows the wired glyph+iface instead of Offline. +When Ethernet is active, replace the offline-WiFi presentation with the wired +interface glyph and interface name. +** DONE [#C] Let the clock panel dismiss itself on right click :feature:clock:waybar: +CLOSED: [2026-07-19 Sun] +Shipped dotfiles fc9a2b7: secondary-button gesture -> ClockApplication._dismiss hides the open panel. Live-verified with Craig 2026-07-19. +Make a right click inside the open clock panel toggle it closed. Preserve left +click for its established interaction; the Waybar time module remains the +explicit way to reopen the panel. +** DONE [#C] Make the WiFi toggle connect the best available profile :feature:network: +CLOSED: [2026-07-19 Sun] +Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect. +When enabling WiFi, automatically connect to the highest-priority available +saved network instead of requiring a panel selection first. +** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network: +CLOSED: [2026-07-20 Mon] +Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B]. +Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys. +Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only). +Follow-ups filed below: harden cgit exposure; installer no longer ships configs. +** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success. +Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C]. +archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2). +** DONE [#C] Installer nvme early module never built into initramfs :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py. +Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C]. +archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3). +** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py. +Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C]. +archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1). +** DONE [#C] Installer run_step state + exit-code handling :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py. +Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C]. +archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1). +** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail: +CLOSED: [2026-07-20 Mon] +Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed. +Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C]. +scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4). +** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py. +Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C]. +archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2). +** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network: +CLOSED: [2026-07-20 Mon] +Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests. +Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C]. +scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4). +** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq. +Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C]. +scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5). +** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true. +Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C]. +tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6). +** DONE [#C] Add inetutils to install base :feature:solo:quick:network: +CLOSED: [2026-07-20 Mon] +Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification. +Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751. +** DONE [#D] Installer resume-idempotency cluster :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py. +Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots. +crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3). +** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py. +Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D]. +With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3). +** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg. +Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D]. +scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4). +** DONE [#D] VM test-framework robustness cluster :bug:test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin). +Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit. +scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5). +** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib). +Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor. +docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7). +** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned. +Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit. +scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7). +** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo: +CLOSED: [2026-07-21 Tue] +Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence). +Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B]. +archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3). +** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles: +CLOSED: [2026-07-21 Tue] +Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste. +Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule. +** DONE [#B] Build: desktop-settings panel :feature:hyprland:dotfiles: +CLOSED: [2026-07-22 Wed] +:PROPERTIES: +:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce +:END: +The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there. +*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237 +Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/. +*** 2026-07-22 Wed @ 15:26:44 -0500 Built the presenters (phase 2) — dotfiles 5172289 +Three GTK-free models per prototype 37, all at 100% line coverage (tests/settings/test_presenters.py, 100 tests; full repo suite green before and after). programs.py: the matrix — eight complete programs (Craig's four factory scenes drafted here per the pre-flight pick, slots 1-4 first-class), pin rows + power radio row, activate returns the full sets, member writes return apply/updated with active-is-live surviving. bench.py: drum mapping (screen never reads 0, floor 5%; kbd floors at 0), idle rail order clamping between enabled neighbors, park/unpark with re-clamp, caffeine bypass, view-state builder tolerant of no-backlight None. channels.py: the eight-channel bank, per-mode sources visibility, alpha/recency sort (unlabeled last), the shared mint/edit/delete grammar for pairs/sets/colors (press arm-cycle, two-picture set minimum, dup rejection, selection clamping), sources guardrails, interval wheel, previews. Handoff note in ~/.dotfiles/inbox/. +*** 2026-07-22 Wed @ 16:03:52 -0500 Ported prototype 37's instruments to GTK (phase 3) — dotfiles 33d82eb +The panel renders P37 end to end. New instruments.py carries the three Cairo instruments as clock-free humble objects: ProgramMatrix (glyph/numbered heads over jewel pins + CPU POWER paper letter wheels), DrumRoller (paper drums, drag-to-set, dimmed n/a on no-backlight machines), TripDial (sqrt 300° scale, colored stage tabs, OFF-notch parking, exact-minutes drag counter, BYPASSED · CAFFEINE stamp, bottom legend). gui.py rebuilt to P37's layout with the wallpaper sub-view: channel bank with drawn faces, minted pair/color/set trays (alpha/time sort, edit/delete chip feet), the three presses (pair arm-cycle, color picker, set press + interval wheel), sources with a folder picker. New GTK-free glue all unit-tested (test_panel_glue.py, 33 tests): dial geometry in bench, matrix/idle/wallpaper wiring in panel, presenter-vocabulary channels (pair/solid/random-from-set) in wallpaper.apply. AT-SPI smoke (make test-panel-settings) drives the real wiring against faked backings + a sandboxed store, pinned to its own child pid so it can never fire a live panel's backings. Visually verified on a headless output against P37 captures (main + pair/single/solid/random). Adaptations recorded in the handoff: five-stage dial (WATCH gets its own green — the engine runs watch separately, P37 merged the label), DESKTOP_SETTINGS_START_VIEW test seam. Full suite 84 suites green; window rule widened for the 540px panel. Handoff note in ~/.dotfiles/inbox/. +*** 2026-07-22 Wed @ 16:47:54 -0500 Integrated phase 4 — dotfiles 680b50d +Bar consolidation had landed early (74f723e); this pass shipped the rest. settings-project hosts the watch/clock/world channels as HTML faces (settings/faces/) on a gtk-layer-shell background window over WebKit2 — all three visually verified on a headless output, world reading the waybar worldclock roster via query param. settings-watch is the hypridle watch-stage host: throwaway-profile chrome kiosk that reveals only after its window maps behind the lock and relocks before teardown — a failed face degrades to the plain lock, never a bare desktop (unlocked lifecycle verified live; the locked swap goes to the manual checklist). Sun-pair location reads whereami live per transition with last-good cache in state.json (verified live: 9.5s first beat, New Orleans coords, Gogh day side applied); desktop-settings-tick.timer (2 min, enabled on ratio, added to the installer) drives flips and random draws — 23ms no-op beats. dunstrc history_length 100 protects held alarms (full DND cycle verified against live dunst; wtimer alarms already CRITICAL via the notify wrapper, no promotion rule needed). Live hypridle rewrite verified — five-stage regime rendered through the stow symlink, caffeine respected (found engaged, daemon correctly left stopped). Refresh signals needed no rewiring (touchpad signals itself via toggle-touchpad). 45 new tests; suite 84 suites green; smoke 13/13. Handoff note in ~/.dotfiles/inbox/. +Velox one-time steps (sync doesn't carry): mpvpaper (AUR), optionally power-profiles-daemon (service off), and systemctl --user enable --now desktop-settings-tick.timer. +*** 2026-07-22 Wed @ 17:05:58 -0500 Landed the 17-point end-to-end pass — dotfiles 9038eee +Prototype 37's 17-point suite re-derived against the real panel (the original Playwright script wasn't preserved; the functional surface in the spec's Final prototype section is the source). tests/settings/panel_e2e.py + run-panel-e2e.sh + =make test-panel-e2e=: points 1-14 drive the running panel over AT-SPI (program recall with per-backing verification across FOCUS/BATTERY/slot1, pointer console keys, all eight wallpaper channels including projected watch/world stop/start ordering, close); points 15-17 cover the Cairo instruments (drums, tripper dial clamp/park/render/reload, matrix pins + letter wheels with active-is-live) at the backing layer, since AT-SPI can't reach a DrawingArea's hit-tests. Same safety posture as the smoke: sandboxed store, faked backings, pid-pinned a11y node. 17/17 green on ratio's live compositor; full suite 85 green; smoke 13/13; ruff clean. The drag gestures go to the manual checklist below. Handoff note in ~/.dotfiles/inbox/. +*** 2026-07-22 Wed @ 17:05:58 -0500 Flipped the spec to IMPLEMENTED +docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits (dotfiles 7a15237 / 74f723e / 5172289 / 33d82eb / 680b50d / 9038eee) and the verification evidence (85 suites, smoke 13/13, e2e 17/17). The four panel drag-gesture checks and the locked-path night-watch swap live under "Manual testing and validation" — human-eye checks, not implementation blockers. +** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland: +CLOSED: [2026-07-21 Tue] +Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving. +Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix. +A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo). +Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it). +Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync). +** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network: +CLOSED: [2026-07-21 Tue] +Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task. +scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). +** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: +CLOSED: [2026-07-21 Tue] +Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green. +Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit. +The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run. +** DONE [#B] Dotfiles tests leak state across files :bug:test:dotfiles:solo: +CLOSED: [2026-07-23 Thu] +Resolved 2026-07-23 as dotfiles =c333598=. The polluter was =tests/weather/test_weather.py=, and it accounted for all 38 failures on its own. + +The mechanism was not the env leak the body below guessed at — tests/weather never writes =os.environ=. Its whereami fake did =weather.subprocess.run = ...= on a freshly-loaded module object. The fresh module isolated the weather code, but =weather.subprocess= is the one shared stdlib module object every module in the process holds, so the assignment replaced =subprocess.run= process-wide and never restored it. Every later test file got weather's fake result back from =subprocess.run=; the tell was wtimer asserting on =r.returncode= and getting "'R' object has no attribute 'returncode'", where =R= is weather's fake result class. + +Triage: TEST HYGIENE, not production global state. The weather script reads env at import and never writes, so no long-lived-process caching defect sits behind it. A scan for the same pattern (patching a stdlib module attribute reached through another module's namespace) finds exactly one instance in the suite — the three other =setattr= sites all snapshot and restore. So the planned shared env helper across 28 files was aimed at the wrong target and wasn't needed. + +Fix: rebind the loaded module's own =subprocess= name to a stub namespace, so nothing outside that module changes and there is nothing to restore. + +Gate: =make test= now runs two gates per the add-don't-replace decision — =test-forked= (one process per file, catches order dependence) and the new =test-shared= (every suite in one process, catches leakage). Built on stdlib unittest rather than pytest, since pytest was only the diagnostic tool and isn't a project dependency. Verified as a real gate, not just green today: with the defect deliberately reintroduced it goes red, and green once restored. A focused test in tests/weather pins the invariant on the culprit as well, because the shared gate alone blames the three victim files. + +Verification: 3500 tests, both gates, exit 0. + +Original finding follows. + +Found 2026-07-23 during the speedrun. =make test= is green, but it runs each test file in its own =python3 -m unittest= process, which hides cross-file state leakage. A single-process whole-tree run (=python3 -m pytest tests/ -p no:randomly=) fails 38: 22 in =tests/wtimer/test_wtimer.py=, 10 in =tests/zoom-web/test_zoom_web.py=, 6 in =tests/wlogout-menu/test_wlogout_menu.py=. + +Not a regression — a worktree at the pre-speedrun commit produces the identical 22/10/6 profile, so this predates tonight's work. Those three files also pass cleanly when run together (170 passed), so the polluter is a fourth file somewhere in the tree that mutates global state (env var, cwd, or a module-level patch) without restoring it. 28 test files write =os.environ= directly. + +Why it matters: the green gate can't see this class of bug, so a real isolation defect — or a genuine failure that only appears under a different order — passes CI silently. Bisect by running the tree with subsets until the polluter is identified (pytest's =-p no:randomly= keeps the order stable while bisecting), fix its cleanup, then decide whether =make test= should gain a single-process pass so the gate covers it. +** DONE [#B] Wallpaper view freezes the panel — thumbnail decode :bug:dotfiles:solo: +CLOSED: [2026-07-23 Thu] +Craig reported 2026-07-23: selecting the wallpaper button freezes the module and the compositor asks whether to kill it. Root cause proven: =_Thumb._draw= decoded each source image with =new_from_file_at_scale= on the GTK main thread. Measured on Craig's 78 wallpapers — a viewport of the 8 largest takes 3.7s, the whole set 13s. That block trips Hyprland's "not responding" watchdog. + +Grading: Critical severity (panel unusable, watchdog kill) × every user every time the wallpaper view opens = P1 = [#A] by the matrix. Held at [#B] because step 1 already shipped and removes the user-visible freeze; the remainder is a latency enhancement, not a showstopper. + +*** 2026-07-23 Thu @ 15:40 Step 1 — async decode (dotfiles f45f321) +Moved the decode to a worker thread via a new =settings/thumbcache.py= (pure, injected decode/scheduler/thread; 6 tests). The thumb shows its dark ground until the pixbuf lands, then redraws. Verified live on a headless output: worst main-loop stall opening the pair view dropped from multi-second to 68ms; the cache filled with 81 decoded pixbufs (the one miss is a .webm, correctly falling back to the ▶ glyph). Full suite 3512, both gates, smoke OK. This alone fixes the reported freeze. + +*** 2026-07-23 Thu @ 16:30 Step 2 — persistent on-disk cache (dotfiles 463cc4f) +Built the persistent layer: =settings/thumbstore.py= decodes each source once to a 512px PNG under =~/.cache/settings/thumbs=, keyed by path + mtime so an edited wallpaper self-invalidates. The hot-path decode reads that PNG and scales in-memory. Warming rides the existing =settings tick= CLI verb (the 2-min timer already runs it), building up to =WARM_PER_BEAT=8= missing thumbnails per beat — best-effort, journals a line on failure, never blocks the wallpaper flip. thumbstore is pure (stat/decode/load/save injected); 10 tests. + +Went with incremental warming (8/beat, ~10 beats to full) as the safe default rather than full-warm-on-change — the per-beat cap is a one-line flip if Craig wants it faster. Measured: hot-path decode of a viewport dropped from 3.7s cold to 47ms warm. No installer change (the tick service already runs =settings tick=); cache lives outside the repo. Full suite 3522, both gates, smoke OK, live panel verified (81 pixbufs render, 48ms worst stall warm). +** DONE [#C] Panel scrollbars too short :bug:dotfiles:quick:solo: +CLOSED: [2026-07-23 Thu] +Shipped 2026-07-23 as dotfiles =0d64837= (22px scrollbar, 16px trough, 14px slider thickness with a 48px floor along the travel axis). Left open by oversight during the speedrun; closing now. + +Follow-on, and my own regression: enlarging the bar to 22px is what made it start covering the thumbnails, because nothing grew the tray to match. Craig reported it the same day ("scrollbars that obscure the images") and it's fixed in =c0ddf57= — the tray now reserves a 22px lane for the bar as a margin on the scrolled box, so the bar sits below the images instead of across them. Measured before: tray 68px, content 68px, a visible 14px bar inside the same 68px. After: tray 90, content 68, bar clear. The lane is a constant under the scrollbar CSS with a note to keep the two in step, since the coupling between bar thickness and tray height is exactly what broke. + +From the roam inbox (Craig, claimed 2026-07-23): all scrollbars need to be much taller than before. The always-visible scrollbars shipped in 7e8eb4a set =min-height: 10px; min-width: 10px= on the slider (=settings/src/settings/gui.py=, the =.dupre-panel scrollbar slider= rule) — that's the floor for a short slider, and the trough itself is thin. Raise both the slider floor and the trough thickness so the bar is comfortably grabbable. Cosmetic × every glance at the wallpaper trays = P3 = [#C]. +** DONE [#C] Velox refresh sweep :chore:maint: +CLOSED: [2026-07-23 Thu] +From the roam inbox (Craig, claimed 2026-07-23): velox needs bringing up to date, the mouse/touchpad module is still there, investigate what else didn't move over. + +Resolved 2026-07-23 by a full sweep over tailscale. The touchpad module was already gone — velox's running waybar (started 01:05, after the reboot) and its tracked config both carry zero =custom/touchpad= entries; what Craig saw was the pre-restow waybar process from before the reboot, and the reboot cleared it. Sweep results: both machines at dotfiles f9b6404 (all three hyprland lock/exit fixes live on velox, config errors clean, =allow_session_lock_restore= reads true); stow restow clean, only the expected skip-worktree files; rulesets pulled to 50fc7ca and =make install= run (agent-text verified working by invoking it — an earlier "MISSING" reading was a PATH artifact of the non-interactive ssh shell, not a real gap); desktop-settings tick timer active; mpvpaper, power-profiles-daemon, gtk4-layer-shell, webkit2gtk all present. + +Genuine remaining differences, all per-machine installs rather than sync failures: =cmail-action=, =gcalcli=, and =playwright= aren't installed on velox, and =obsbot-wb-guard.service= isn't enabled there (the OBSBOT lives on ratio). None block anything; file separately if velox should send mail or drive browser tests. +*** 2026-08-20 Thu @ 09:53:04 -0700 One of those three closed itself; two still stand +=cmail-action= is on both daily drivers now, and nothing did it deliberately. It moved into rulesets at =claude-templates/bin/=, and rulesets' =make install= links that whole directory into =~/.local/bin= at every session start — so velox picked it up on its own. Verified here: the symlink was written 05:44 this morning by this session's own startup, and the tool runs. + +=gcalcli= and =playwright= are still absent on velox, which stays correct until I say velox should send calendar invites or drive browser tests. =obsbot-wb-guard= is still right to be off here; the camera is on ratio. + +Leaving the paragraph above as written rather than striking it (rulesets suggested striking). It is the resolution note of a task closed 2026-07-23 and it was accurate that day. Editing a closed record to match today makes it a worse record, and the useful correction is this dated entry, not a redaction. +** DONE [#C] Weather tooltip sunrise and sunset :feature:waybar:weather:quick:solo: +CLOSED: [2026-07-23 Thu] +Shipped 2026-07-23 as dotfiles =de62e9d=. The two rows sit directly below Humidity in the current-conditions block, rendered in the footer's 12-hour format (=%-I:%M %p=) so the tooltip reads one way throughout. + +Confirmed the no-extra-round-trip premise held: =sunrise,sunset= joined the existing =&daily== block. Split =forecast_url= and =reading_from= out of =fetch= so both the request and the reading are testable without network — that's what let the new cases cover a payload missing the fields. Six tests (Normal/Boundary/Error): row placement and format, a pre-change cache with no sun fields, an unparseable stamp, today's pair picked out of the six-day arrays, and the API omitting them. Reused the existing =_at= helper rather than adding a near-duplicate =_first=. + +Live-verified against the real API: sunrise 6:14 AM, sunset 7:59 PM for today in New Orleans, rendering in the actual tooltip. Full suite 3506 tests, both gates, exit 0. + +Open, not blocking: every other header row carries a glyph (thermometer, droplet, wind arrow) and the sun rows are plain text. The file's glyphs are marked font-confirmed codepoints, and I haven't verified a sunrise/sunset glyph renders rather than showing tofu, so I left them bare. Craig's call. + +From the roam inbox (Craig, claimed 2026-07-23): in the weather module's hover text, the section immediately after the location ends with the current humidity. Add the sunrise and sunset times for the current location directly below it. + +Cheap to source: the module already calls Open-Meteo with a =&daily== block (=common/.local/bin/weather=, the forecast URL around line 336), so =sunrise,sunset= joins that same request with no extra round trip — normalise_daily already parses the daily arrays. Times arrive as local ISO strings; render in Craig's canonical clock format rather than re-deriving one. The settings package's =suntimes.py= (pure NOAA math, no network) stays the offline fallback path if the API field is ever absent — don't duplicate its math here. +** DONE [#C] Maint doctor-row copy button :refactor:maint:quick:solo: +CLOSED: [2026-07-23 Thu] +Shipped 2026-07-23 as dotfiles =761fa5c=, "fix(maint): drop the COPY key from the doctor row" — the key and its orphaned handler removed from =maint/src/maint/gui.py=. =viewmodel.status_copy_text= stays: it's a tested pure serializer and the obvious source if a copy surface returns somewhere better placed. + +Correction to the body below: it describes a per-row button and a separate global one. There is only one COPY key, and it IS the global one Craig added in 8bc79ba two days earlier. He tried it and wanted it gone, so the row now reads DOCTOR · CLEAN UP · REVIEW & FIX. + +From the roam inbox (Craig, claimed 2026-07-23): remove the per-doctor-row copy button (next to REVIEW and FIX) from the maint status wall. The global COPY key (dotfiles 8bc79ba, "one global button copying rendered text") stays the one copy surface — the per-row button turned out to be clutter next to it. +** DONE [#C] WiFi tooltip signal strength :feature:waybar:network: +CLOSED: [2026-07-22 Wed] +From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip. + +Resolved 2026-07-22: the tooltip's signal line existed but never fired on ratio — the mt7925 driver leaves /proc/net/wireless empty (legacy WEXT procfs unimplemented), so the dBm read returned None and the bar glyph fell to the weakest tier. Fix in dotfiles net/: an iw-dev-link nl80211 fallback (only spawns when procfs is empty), a signal_percent mapping, and an enriched line — Signal: ▂▄▆█ 100% · -32 dBm (excellent) — bars by band, percent, raw dBm, band word. The bar icon tier fixed itself as a side effect. +** DONE [#B] Desktop-settings dropdown panel :feature:waybar: +CLOSED: [2026-07-22 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-22 +:END: +Resolved 2026-07-22: shipped end to end via the "Build: desktop-settings panel" task (dotfiles 7a15237 → 9038eee; spec IMPLEMENTED, 85 suites + smoke 13/13 + e2e 17/17). Every open question below got settled in the spec: bar consolidation landed (74f723e), the wallpaper manager became the in-panel sub-view, and the format pickers split into their own sibling spec ([[file:docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org]], DRAFT stub). Remaining human-eye checks live under "Manual testing and validation". + +Original body follows as the record. + +Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack). + +One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate: +- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it). +- *Brightness* slider (backlight, via brightnessctl). +- *Keyboard-backlight* brightness slider (brightnessctl on the kbd_backlight class). +- *Mouse* enable/disable toggle — shown only when a mouse is connected. +- *Trackpad* enable/disable toggle — shown only when a trackpad is connected (mirror =toggle-touchpad= / =touchpad-auto=). +- *Idle inhibitor* (the =custom/idle= module that replaced the built-in =idle_inhibitor= 2026-06-24 — toggles the hypridle daemon, state-synced icon). +- *Airplane mode* (the existing =airplane-mode= toggle; laptop-only). + +The conditional rows (mouse, trackpad, airplane) appear only when their hardware/context applies — reuse the laptop/device detection the airplane and touchpad indicators already do. + +Design / open questions (propose before building): +- Panel tech: sliders need a real toolkit (waybar can't host a slider), so a GTK4 + gtk4-layer-shell app like pocketbook is the likely shape. +- Which existing standalone bar modules (dim, touchpad, airplane, idle_inhibitor) collapse INTO this panel vs. stay on the bar as quick-access indicators. Craig's call. + +Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first. + +Home handoff 2026-07-19 (inbox, resolving the open "few other things" decision — fold into the spec, close the open decision, extend the controls table, then run spec-review, may flip DRAFT→READY). Ownership: home drives the build (dotfiles settings/), archsetup keeps the canonical spec. Full reconciliation in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org. +- ADD controls: night-light / color temperature; Do Not Disturb / notifications (dunst); lock / suspend quick actions; power profile (performance/balanced/saver); scenes/profiles — one control flipping several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything. +- OUT (record reasons): volume / master-mute stays with the audio panel (no mirror here); theme light/dark goes to the theme-studio task. +- FORMAT PICKERS pulled to their own future sibling spec — time/date/weather format is out of THIS panel. Rationale: format settings live in many programs, so the design problem is a single source of truth for the canonical format. Track a future sibling-spec stub in docs/specs (time/date/weather format single-source-of-truth); Craig thinking it through separately, not started. +- STILL OPEN (spec already flags): wallpaper manager confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec undecided — resolve at spec-review. +** DONE [#C] Gallery probe: the fader-drag check is flaky :bug:test:design:quick:solo: +CLOSED: [2026-07-23 Thu] +Fixed 2026-07-23. Root cause confirmed rather than suspected: =panel-widget-gallery.html= line 74 sets =html{scroll-behavior:smooth}=, so =scrollIntoView= animates and the fixed 200ms sleep sometimes read =getBoundingClientRect= mid-scroll. The drag then dispatched at stale coordinates, the press missed the fader, and the check reported a dead widget. + +Fix: scroll with =behavior:'instant'=. The probe never needed the animation, so this removes the race instead of waiting it out. Also added a =settledRect= guard (rect stable across two reads AND on-screen) for zoom/column relayout, and a =hits()= assertion that the press actually lands on the fader before the drag goes out. + +Applied to the toggle-click check too — it shares the same fixed-sleep shape, and it failed for this exact reason during the diagnosis, so fixing only the fader would have left half the defect. + +Worth recording: my FIRST fix was wrong and made it worse. Polling until the rect stopped changing returned pre-scroll coordinates every time, because two identical samples are also what you get before the animation starts — an intermittent failure became a consistent one. The new hit-test assertion is what caught it, printing the press point at y=1326 against a 1200px window. That's the argument for asserting the press landed rather than only asserting the readout moved. + +Verified against the measured 1-in-6 failure rate: 8 consecutive runs, all three checks passing, with the press point identical every run (429,480) — deterministic, not lucky. Full probe 96 PASS, 0 FAIL, exit 0. + +=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch. + +Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=. + +Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured. + +Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape. + +*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug). + +Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. + +Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). +** DONE [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:quick:solo: +CLOSED: [2026-07-23 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +Closed 2026-07-23. The last open item was the velox check, and velox is reachable again. Pulled it from =02df01a= to =de62e9d= (clean tree, fast-forward), then ran =make conflicts hyprland=, which dry-runs every tier: "No stow conflicts", exit 0. Its old conflict copy had already cleared against the updated repo, so there was nothing for =make reset= to do. + +Verified the pull is live through the symlinks rather than just present in the repo: =~/.local/bin/weather= resolves into the dotfiles tree and returns today's sun times on velox. + +Note for the record: =make conflicts common= is not a valid invocation — =check-de= rejects it, because common and the host tier are auto-included in the DE-scoped run. =make conflicts hyprland= is the whole check. +*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion) +Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo. +*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) +The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). +From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). + +Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check): +- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder. +- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there. +- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.) +** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then. + +WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock). + +ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility. + +THE FIX, four layers, research-validated: +1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot. +2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines. +3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer. +4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice. + +REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=. + +PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=. + +Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion. + +Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio. +** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it. + +SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates. + +THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate. + +THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES: + +1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages. + +2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses. + +3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive. + +AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount. + +ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads. + +The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution. +** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red. + +Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent. + +=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness: + +- =bt/repair.py= =repair_service_restart= +- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers) +- =net/repair.py= =repair_unmask_nm= + +So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour. + +All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.) + +THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it. + +Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember. + +NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct. +** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures). + +Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path. + +THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one. + +Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both. + +Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid. +** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller. + +Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages. + +The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing. + +The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error. + +DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME. + +Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B]. +** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2. + +Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these. + +THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling. + +1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source. + +2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state. + +Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can. + +WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0. + +Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C]. + +THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks. +** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red. + +Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low. + +Three defects, all of which make the script's final word untrue: + +1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0. + +2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive. + +3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions. + +Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe. + +Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C]. + +Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message. +** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1. + +Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees. + +Two defects, both verified by running the script rather than argued: + +1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone. + +2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why. + +Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get. + +Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B]. + +Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call. +** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests. +=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code. + +=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=. + +The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent. + +Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit. + +Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D]. + +Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out. +** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure). + +Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=. + +Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued: +- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright. +- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it. +- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success. +- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither. + +Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover. + +The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks. + +Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B]. + +Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output. +** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests. +Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix. + +Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine). + +=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses. + +Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for. + +Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D]. + +Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path. +** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively). + +Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=. + +Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script. + +The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling. + +Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way. + +Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B]. + +Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect. +** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected. +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on. + +CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated. + +What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent. + +Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped. + +Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B]. + +Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file. +** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests. +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument. + +#+begin_src sh +*) + echo + "Invalid option chosen." + echo + "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ." + ;; +#+end_src + +The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only. + +Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C]. + +Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout. +** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests. +Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan. + +Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here). + +Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8. + +Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C]. + +Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache. +** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands. +Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed. + +The other 17 zones the script sets all resolve correctly, so this is the single bad entry. + +Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C]. + +Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database. +** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it. + +This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids. + +Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C]. + +Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection. +** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition. + +Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early. + +The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests. + +Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C]. + +Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe. +** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored". + +So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS. + +The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load. + +Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B]. + +Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side. +** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely. + +Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level. + +This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug. + +Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B]. + +Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite. +** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=. +Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed. + +Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry. + +Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D]. + +Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=. +** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=. + +The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it. + +Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use. + +What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header. + +Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B]. + +Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes. +** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless. + +That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there. + +CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both. + +The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment. + +Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C]. + +Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path. +** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other. + +Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*. + +Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on. + +The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone. + +Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C]. + +Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't. +** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach. + +Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=. + +What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it. + +Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C]. + +Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix. + +The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed. +** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=. + +=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread. + +Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits. + +What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it. + +Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C]. + +Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling. + +Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too. +** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once. + +=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol. + +Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B]. + +Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal. + +New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom. + +THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found. +** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes. + +=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot. + +The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't. + +Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C]. + +Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for. + +HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard. +** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests. +Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks. + +=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't: + +#+begin_src python +def _wp_apply(self, note="Wallpaper set"): + self._async(lambda: panel.wallpaper_apply(self.state), + lambda ok: self._toast( + note if ok is True else "nothing to apply", + good=ok is True)) +#+end_src + +=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=. + +Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D]. + +Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines. +** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end. +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing: + +- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=. +- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception. + +Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback. + +Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D]. + +Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites. +** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint. +Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=: + +- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps) +- =maint/src/maint/cache.py= =put= +- =maint/src/maint/curation.py= =_write_user= + +=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it. + +Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs. + +Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D]. + +Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision. +** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout = where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all. + +CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works. + +What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either. + +Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one. + +SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second. +** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org). + +Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call. +** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above). + +Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True. +** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C]. + +Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT. +** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C]. + +Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log. +** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles: +CLOSED: [2026-07-24 Fri] +Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it. +** DONE [#C] Floating layout — should we? :feature:hyprland: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation. + +CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode": +- Can't select floating until at least one window is displayed. +- Entering floating freezes each window's position and floats it exactly where it is. +- During floating, drag windows with mod+mouse-drag. +- Exiting floating switches to tiling or monocle and lets that layout take over. +Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending. + +ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go. +** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task. + +Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig. +** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module. + +This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable. + +Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm. +** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable. + +Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle. +** DONE [#C] Saving and recalling window configurations :feature:hyprland: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent. + +RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept. +** DONE [#B] Weather tooltip caching :feature:waybar:weather:solo: +CLOSED: [2026-07-25 Sat 10:53] +From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather. +Verified complete in the 2026-07-25 batch: the weather CLI already had the hourly default TTL, fresh-cache no-fetch path, stale fallback, and explicit offline footer. Its 33-test suite and the full dotfiles suite pass. +** DONE [#B] Settings gear becomes four device toggles :feature:waybar:dotfiles:solo: +CLOSED: [2026-07-25 Sat 10:53] +From the roam inbox (Craig, claimed 2026-07-23): the waybar gear should become four icons — touchpad, mouse, webcam, and a notification bubble. Clicking each toggles that setting directly. The first three turn red when disabled; the bubble turns red when DND is enabled. + +Today =custom/settings= (=hyprland/.config/waybar/config=) is one gear glyph () whose only job is =on-click: settings-panel=. The toggles themselves already exist and are tested — the settings package owns touchpad, mouse, and webcam (=webcam.py= is the USB-authorized kill switch from 2026-07-22), so this is a bar-side surface over existing backends rather than new capability. + +Note the state-polarity split when wiring the colors: three read "red = off" and DND reads "red = on". That asymmetry is deliberate (red means "something is disabled that normally isn't, or suppressed that normally isn't"), so encode it per-icon rather than deriving one rule. + +Decided 2026-07-23 (Craig): the gear STAYS alongside the four toggles as the panel launcher. So the bar's right side grows from 12 modules to 16 — the four toggles are net-new, the gear keeps its =on-click: settings-panel=. Open sub-question for build time, not blocking: whether the four toggles are four separate waybar modules or one custom module rendering four glyphs (fewer layout entries, one exec). Pick at build; the four-module shape is simplest and matches how mic/net already sit as individual modules. +Shipped in the 2026-07-25 batch as four independent JSON modules over the existing verified settings backends. Touchpad, mouse, and webcam turn terracotta when disabled; DND uses the deliberate inverse polarity; unavailable hardware dims. The gear remains the panel launcher. The live and Dupre theme CSS copies stay byte-identical. +** DONE [#C] Wallpaper panel selection and scroll state :feature:dotfiles:solo: +CLOSED: [2026-07-25 Sat 10:53] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-25 +:END: +From the roam inbox (Craig, 2026-07-25). Screenshot: =~/pictures/screenshots/2026-07-25_013041.png=. Three related behaviors in the settings wallpaper panel (=settings/src/settings/wallpaper.py=): +1. Open at the wallpaper currently displayed, not the top of the list. +2. Highlight that wallpaper as selected in the scrollable pane while it shows in the preview. +3. Keep the scroll position when a picture is selected. Today selecting a picture snaps the scroll back to the top, which is the bug half of this. +Grade: minor scroll-reset defect x every panel selection = P3 = [#C]; the open-at-current and select-current behaviors are enhancements at the same level. One type tag, so filed =:feature:= with the scroll-reset called out as the bug. Solo: buildable in the settings GTK panel, agent-verifiable via headless capture plus the wallpaper.py tests, no design call — swww query gives the current wallpaper, and scroll-position preservation and row selection are standard GTK. +Shipped in the 2026-07-25 batch. The panel queries =awww query= off the UI thread, prefers the actually displayed image over stale stored state, highlights it, scrolls it into view on first open, and remembers the horizontal adjustment across selection-triggered rebuilds. +** DONE [#C] Net tooltip IPs and line order :feature:waybar:network:solo: +CLOSED: [2026-07-25 Sat 10:53] +From the roam inbox (Craig, claimed 2026-07-23): in the wifi hover, add the internal IP, external IP, and gateway IP just below the Interface line; move the Signal line to just above the keyboard-shortcuts line. Design constraint: the bar's hot path does no network I/O (status.py deliberately skips _address_facts on the 2s beat) — internal IP + gateway can ride cheap local reads, but the external IP must come from a cache the connectivity probe refreshes, never a live lookup in waybar-net. +Shipped in the 2026-07-25 batch. The slow connectivity probe caches local addressing and a validated external IP with the network identity; the Waybar hot path only reads that valid cache. Tooltip order is Interface, internal/external/gateway IPs, connectivity detail, throughput, Signal, shortcut. +** DONE [#B] Dupre Kit merge — casting additions :feature:tooling:solo: +CLOSED: [2026-07-25 Sat 10:53] +Fold docs/prototypes/dupre-kit-additions.js back into the kit proper: detentFader (NEW — multi-detent slide attenuator with speedbump drag physics: magnet + escape hysteresis, parked tick glow) and the drumRoller redefinition (UPGRADE — 1..N channels and min/max range; stock hardcodes two drums and throws on one, defaults reproduce stock exactly) and the guardedToggle redefinition (UPGRADE — lever throws with rotateX so it flips toward the viewer instead of the stock 180° planar spin that sweeps sideways mid-transition; contract unchanged). Merge means: builders into widgets.js, the additions CSS into DUPRE_CSS, additions-scoped gradients into the shared defs plate, gallery cards for both in panel-widget-gallery.html, and POLICY entries. Origin: the desktop-settings casting sitting 2026-07-21 — Craig's direction is that components get finished by being needed ("the ones needed most will have had the most attention"), so more additions may accrue here before the merge; batch them. +Shipped in the 2026-07-25 batch. =widgets.js= now owns all three builders, shared gradients/CSS, contracts, and policy records; additions no longer redefines them when older casting pages load it. The gallery has a three-detent fader card and a three-channel 0–12 drum demonstration (112 cards total). Static ownership tests, JS syntax checks, and the complete headless interaction probe pass. +** DONE [#C] Maint live-refresh hairline replacement :feature:maint:solo: +CLOSED: [2026-07-25 Sat 10:53] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. +*** 2026-07-21 Tue @ 08:35:00 -0500 Decided (Craig): silent-until-stale age caption +Replace the draining 2px hairline with an age caption that shows ONLY when refresh is overdue (e.g. "3s", "8s" once past the expected window) and shows nothing while the board is healthy. This keeps the stale-vs-frozen signal — a frozen board surfaces a growing age number, a live one stays clean — while removing the constant motion the hairline created. Implementation (dotfiles, archsetup-owned): drop =gui._hair_tick= / the hairline draw, add an overdue-age caption driven off =viewmodel.refresh_fraction= (or the last-refresh timestamp) rendered only past the live window. Now unblocked; needs a live visual check on the panel after. +Shipped in the 2026-07-25 batch. The animated draw area and 150ms timer are gone; the memory section header stays silent through the healthy three-second window, then shows a once-per-second growing age caption. Pure boundary tests and the full maintenance suite pass. +** DONE [#D] Test-framework + prototype refactor cluster :refactor:solo: +CLOSED: [2026-07-25 Sat 10:53] +Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above. +scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7). +Completed test-first in the 2026-07-25 batch. QEMU and bare-metal runners share liveness/report helpers; maintenance transports share scenario validation/execution; token emitters share ordered section traversal; and the Emacs SVG gauge shares semicircle geometry and uses the public DOM append API. Every fast Python/ERT suite passes. +** DONE [#B] Two agent sessions sharing one git repo :chore:tooling: +CLOSED: [2026-07-26 Sun] +Craig approved the shared-rules-layer solution on 2026-07-26. + +Use one repository-scoped publish lock for every session and worktree sharing a clone. Derive the lock name from the real Git common-directory path; hold it across reconcile, stage, staged review, and commit; track the owning session and reviewed staged-tree fingerprint; refresh it after conversational waits; and repeat the staged review if ownership or the fingerprint changed. Ordinary working-tree edits remain concurrent. + +An approval waiver never waives the staged review, because that review is the gate that reads the actual hunks entering the commit. Rulesets owns the implementation in =commits.md=, =agent-lock=, and its Bats coverage; archsetup sent the approved implementation package through the rulesets inbox. +** DONE [#A] Reboot ratio to activate amdgpu.runpm=0 :bug:hyprland:ratio: +CLOSED: [2026-07-28 Tue] DEADLINE: <2026-07-28 Tue> +:PROPERTIES: +:CREATED: [2026-07-28 Tue] +:LAST_REVIEWED: 2026-07-28 +:END: +Craig's plan: close everything down, run topgrade, then reboot. Alarm set for 08:00 (=at= job 56, persistent desktop notify). + +=amdgpu.runpm=0= sits in =/etc/default/grub= and in the generated =/boot/grub/grub.cfg= (5 occurrences, so the reboot will actually apply it) but is absent from =/proc/cmdline=. The box has been up since 2026-07-22 21:10 and the fix landed 2026-07-24, so the running kernel predates it. The GPU is AMD Strix Halo (Radeon 8060S, =1002:1586=), exactly what the parameter targets: runtime power management invalidates the GPU resources hyprlock holds across a display power-cycle, so hyprlock exits without unlocking. + +That is the root cause under the 2026-07-27 lockdead screen. The screen-lock flock fix (dotfiles =ec18fd7=) stops one dead client from becoming a lockdead screen, but it treats the symptom -- this reboot treats the cause. + +Not :solo: — Craig closes his own session and runs topgrade first. + +Rebooted 2026-07-28 08:59. =amdgpu.runpm=0= confirmed present in =/proc/cmdline= afterward, so the parameter is finally live. + +Correction, 2026-07-29: the claim above and in the body that this is "the root cause under the 2026-07-27 lockdead screen" is wrong, and superseded. hyprlock was never crashing. Every logged exit is =rc=143=, SIGTERM, from =settings-watch= killing it by design. See =[#B] Night watch and the lock watchdog fight each other=. The reboot was still worth doing (the parameter is a genuine mitigation for a real AMD defect) but it did not fix this, and the lockdead screens continued after it. +** DONE [#B] Caffeine state is unreadable on both surfaces :bug:dotfiles:design:solo: +CLOSED: [2026-07-28 Tue] +:PROPERTIES: +:CREATED: [2026-07-28 Tue] +:LAST_REVIEWED: 2026-07-28 +:END: +Neither surface that reports caffeine tells the truth reliably, so there is no way to know at a glance whether the screen will lock. Found while investigating the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway. + +Defect 1 — the settings panel shows a frozen value. =gui.py= calls =_refresh_async()= once during window construction (line 454) and again only after the user's own actions (=_after_matrix=, line 663). The only two =GLib.timeout_add= calls are one-shots (the 2400ms toast hide and a 350ms fire), so nothing re-reads state on a timer. An open panel therefore displays the caffeine value from the moment it opened, forever. Any external flip -- the waybar click, Super+I, the =caffeine-toggle= script -- leaves it stale with no self-correction. The panel is the only surface in the repo carrying a caffeine control (=panel.py:25=); maint has none and does not embed these toggles, so this is the display Craig read. + +Defect 2 — there is no caffeine indicator on the bar at all. =custom/caffeine= appears in neither the stowed =hyprland/.config/waybar/config= nor the live generated =/run/user/1000/waybar/config=, and no =custom/caffeine= block is defined anywhere in the waybar config dir. The =waybar-caffeine= script exists, works, and has its own passing test suite, but nothing displays it. So the bar has never been a source of caffeine state, and the keybind and script have been signalling (=pkill -RTMIN+8 waybar=) a module that isn't there. + +(An earlier read of this task said the bar showed two near-identical glyphs. That was wrong: the module is absent, not merely unstyled. The script's class names are still backwards -- =active= when caffeine is OFF, =inhibited= when ON -- and neither class is styled, but both points are moot until the module is actually in the bar.) + +Grading: Major severity (the panel reports state wrongly while it is open, and the only other surface does not exist, so there is no reliable source for a setting Craig actively manages) x most-of-the-time (any external toggle while the panel is open; the bar never shows it) = P2 = [#B]. + +Fix all three. Wire =custom/caffeine= into the bar, rename its classes so they describe caffeine rather than idle, and style them from the existing palette. Give the panel's toggle row a re-read on a timer or on focus-in. Solo -- buildable and testable, and the direction is settled by the defects rather than a taste call, though the bar color is worth a glance from Craig once it renders. + +All three shipped as dotfiles =033076c=, pushed. =custom/caffeine= now sits in the bar between DND and settings on =interval: 2=; classes renamed =on=/=off= and both styled, caffeine-ON in the theme's gold =#dab53d=; the panel re-reads live state every 3s while visible. Verified live in the stowed config and the generated =/run/user/1000/waybar/config=. The full suite caught a theme-copy regression (=themes/dupre/waybar.css= out of sync with =waybar/style.css=) that the focused suites missed. +** DONE [#B] hyprlock still exits mid-lock; the watchdog relaunch is silent :bug:hyprland:dotfiles: +CLOSED: [2026-07-29 Wed] +:PROPERTIES: +:CREATED: [2026-07-28 Tue] +:LAST_REVIEWED: 2026-07-28 +:END: +Craig, 2026-07-28 ~15:00: saw the Hyprland lockdead/error text blurred *behind* a working lock screen; it vanished when he authenticated. + +That ordering is the diagnosis. hyprlock's blur samples what the compositor is currently rendering, so the compositor was already showing lockdead when the new hyprlock attached. Sequence: hyprlock exits non-zero (no coredump, so it exits rather than crashing), Hyprland renders lockdead because the client is gone while the session stays locked, =screen-lock='s watchdog relaunches within =LOCK_RELAUNCH_DELAY= (0.5s), and the new client draws over the lockdead frame and blurs it. + +*The recovery worked.* On 2026-07-27 this same hyprlock exit produced two contending clients and a session recoverable only from another console. It now self-heals in half a second, and the residue is cosmetic. Both the flock guard (dotfiles =ec18fd7=) and the watchdog did their jobs — verified in this session's compositor log, where all four lock events created exactly one =sessionLock= and one =sessionLockSurface= each, against two of each on 2026-07-27. + +Two things remain. + +*Why hyprlock exits.* The wrapper's header blames GPU-resource invalidation across a display power-cycle (hyprlock#953), which =amdgpu.runpm=0= targets — and that parameter is live as of the 2026-07-28 08:59 reboot, confirmed in =/proc/cmdline=. There is also no DPMS idle rule any more (=e900903=), so idling never power-cycles the display. Yet hyprlock still exited. Strongest untested candidate: a screen recording (=wf-recorder= into =~/sync/recordings/2026-07-28-12-53-57.mkv=, running 12:53 until Craig killed it) held screencopy sessions on DP-4 across the lock. The compositor log carries 2454 screenshare sessions and a =CScreencopyProtocol= bind in the window between the last two locks. A screencopy client churning dmabufs alongside hyprlock's own is a plausible way to invalidate them, and it was the one large new variable that day. + +*The relaunch is silent.* The watchdog loop re-runs hyprlock and logs nothing, so there is no record of how often this fires, when, or with what exit code — which is exactly why the frequency couldn't be established from the logs. Log the exit code and a timestamp on each relaunch. + +Grading: Major severity (the lock client dies mid-lock, and the pre-fix version of this wedged a session unrecoverably) x most users frequently (twice in three days, and this is a single-user machine, so every occurrence lands on the only user) = P2 = [#B]. Downgraded from the 2026-07-27 [#A] because the wedge is fixed and the failure now self-heals. + +An earlier draft of this grading said "some users sometimes", which the matrix maps to P3 = [#C], not the [#B] written beside it. The frequency row was the wrong input rather than the letter: on a one-user machine a fault hitting twice in three days is frequent, not occasional. Corrected the input per the rule that a disputed grade is fixed at its inputs. + +Solo for the instrumentation half only: adding the relaunch logging is buildable, testable against the existing =tests/screen-lock= suite, and needs no decision. Diagnosing the exit is not solo — it needs a reproduction, and the likely trigger is Craig recording his screen. + +Next step when picked up: land the relaunch logging first so the next occurrence produces evidence, then try to reproduce by locking with =wf-recorder= running. + +Superseded 2026-07-29 by =[#A] Night watch and the lock watchdog fight each other=. The logging landed (dotfiles =5bbe2c3=) and answered it within hours: three =rc=143= entries, SIGTERM, from =settings-watch= killing hyprlock by design. Nothing was crashing, so both the AMD-iGPU and the screen-recorder hypotheses in this task are wrong. Kept closed rather than deleted because the reasoning that led here is worth the record. +** DONE [#A] Idle commits silently drop the screen-lock wrapper :bug:hyprland:dotfiles:security: +CLOSED: [2026-08-04 Tue] DEADLINE: <2026-07-29 Wed> +:PROPERTIES: +:CREATED: [2026-07-29 Wed] +:LAST_REVIEWED: 2026-07-29 +:END: +Caught live 2026-07-29 05:30, seconds after it happened, while verifying that Craig's watch-stage change had landed. + +=idle.py= renders the *whole* hypridle.conf, including a hardcoded =GENERAL= block. That block said =lock_cmd = pidof hyprlock || hyprlock=. The live config said =|| screen-lock=. So every idle-stage commit through the panel rewrote =lock_cmd= and dropped the wrapper out of the chain. + +The wrapper is not incidental. It carries the flock duplicate guard (the fix for the 2026-07-27 unrecoverable wedge), the crash-relaunch watchdog, and the relaunch log. Parking one stage removed all three in a single write, and nothing said so. + +=tests/settings/test_settings.py:562= asserted the bare =|| hyprlock= form, so the suite *enforced* the regression. That is why 3845 tests stayed green through a day of work on exactly this subsystem. A test can pin the bug as readily as the fix. + +The false-negative this sets up is worth naming: with the wrapper gone the relaunch log stops receiving entries, and an empty log reads as "the problem is fixed" when it means "the instrument was removed". The =screen-lock= header already warns that an empty file is not proof; this is the mechanism that would have produced one. + +Fixed by dotfiles =ab059fb= (2026-07-29 05:59). Verified 2026-08-04 against the tree rather than the commit message: =idle.py:47= renders =lock_cmd = pidof hyprlock || screen-lock || hyprlock=, the live =hypridle.conf= matches, and =test_settings.py= now asserts the wrapper is in the chain plus a second test for the bare-hyprlock fallback. The test that used to pin the bug now pins the fix. + +The evidence that matters is the one this task named: =~/.local/var/log/screen-lock.log= is *receiving entries*, so the instrument is present. An empty log was the false negative to fear, and it did not happen. + +FIXED here, TDD, in the working tree pending commit: +- =idle.py= =GENERAL= now names =screen-lock=, with a comment saying why the line is load-bearing. +- The test now pins the wrapper form. Red first against the old template. +- Live config rewritten through the panel's own path and hypridle restarted; =lock_cmd= confirmed back to =screen-lock=, one hypridle running. + +Grading: Critical severity (=write_conf= truncates, so any hypridle key the renderer does not model is silently deleted rather than preserved — that is configuration data loss, and the =lock_cmd= case proved it happens in the field) x some users sometimes (only when an idle stage is committed, which is rare) = P2 = [#B]. + +An earlier draft graded this [#A] on a "security carve-out". That was wrong: disarming the guard is an availability problem, not a leak, and the carve-out is for privacy, security, compliance and safety. The severity band is what carries the weight here, and silent deletion of configuration is the =Critical= band's data-loss case. + +Two further fixes came out of an independent review of the first one: + +- *Fail-open restored.* =pidof hyprlock || screen-lock= made the wrapper the end of the chain, and =screen-lock= is a stow symlink in =~/.local/bin=, not a system binary. An unstowed tree, or a hypridle started without =~/.local/bin= on PATH, resolves it to 127 — so the screen would never lock *at all*. That is worse than the duplicate client the wrapper prevents. The chain now ends =|| hyprlock=, matching the wrapper's own fail-open discipline. +- *The file now says it is generated.* Three comment lines at the top of the rendered output name the renderer and warn that edits are overwritten. The absence of that header is how the divergence survived unnoticed. + +Still open, and why this stays a task rather than closing with the fixes: the header warns, but nothing *prevents* the next divergence, and the exposure is wider than =lock_cmd= alone. The review enumerated it: + +- =before_sleep_cmd= and =after_sleep_cmd= sit in the same hardcoded block, at identical risk. +- Every stage command is hardcoded in =_stage_commands= (brightness level, lock, watch, dpms, suspend), same one-way overwrite. +- =write_conf= *truncates* rather than merges, so any hypridle key the renderer does not know about (=ignore_dbus_inhibit=, =ignore_systemd_inhibit=, =inhibit_sleep=, =on-lock=, =on-unlock=) is deleted rather than preserved. That is the largest hole: a key nobody has added yet would vanish the first time a stage is parked. + +Options: have the renderer preserve the existing general block and unknown keys instead of emitting its own, or accept the template as the single source and move every hypridle setting into the panel. A design call for Craig, and the truncation half is the part that will bite next. +** DONE [#A] Comet KVM setup for truenas :feature:infra:truenas: +CLOSED: [2026-08-08 Sat] +:PROPERTIES: +:CREATED: [2026-07-27 Mon] +:LAST_REVIEWED: 2026-07-27 +:END: +Resolved: Craig wired up and configured the Comet himself, confirmed working +2026-08-08. The ATX power-board follow-up (hard power-cycle for a truly wedged +box) remains unfiled — raise it if the next outage shows the KVM alone isn't +enough. +Wire up the GL.iNet Comet (GL-RM1) IP KVM against truenas. It was bought 2026-01-14 for exactly this job and its KB node still reads "Arrived, not yet set up." + +Why now: truenas went dark 2026-07-24 and stayed unreachable. Diagnosis from ratio on 2026-07-27 — no tailnet contact for 3 days, 100% packet loss on 192.168.86.5, ARP entry FAILED (nothing answers ARP for the address, so the NIC is down at layer 2), every service port closed, while the gateway and a dozen other LAN hosts stayed reachable. Wake-on-LAN to 70:85:c2:db:9d:94 drew no response. With no console and no out-of-band power control there was no remote remedy at all, so recovery needed hands on the box. The Comet closes exactly that gap: BIOS/UEFI console, Wake-on-LAN, and browser access over its native Tailscale integration. + +Not :solo: — the physical cabling is Craig's, and the Tailscale enrollment needs his account. + +Steps, from the KB node ([[id:67bc5994-a763-48e2-926f-4ac0d1bad3db][GL.iNet Comet (GL-RM1) - KVM]]): +1. HDMI from truenas video out to the Comet's HD IN. +2. USB-A-to-USB-C from the Comet to a truenas USB port (keyboard/mouse emulation). +3. Ethernet to the network. +4. Power via USB-C (5V/2A). +5. Reach the web interface and enroll it in Tailscale, so it's usable when the LAN side of truenas is the thing that's broken. + +Then verify while truenas is healthy, rather than discovering the gaps during the next outage: confirm the console shows POST and the BIOS, that keyboard input reaches the box, and that Wake-on-LAN from the Comet actually powers it on. Enable WOL in the truenas BIOS if that last check fails — this outage never established whether it was on. + +Worth considering as a follow-up: the ATX power board accessory gives hard power-cycle control for a truly wedged box, which the KVM alone can't do. +** DONE [#A] Review post-archsetup laptop setup steps (velox 2026-04-10) +CLOSED: [2026-08-08 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-08-08 +:END: +Closed at the 2026-08-08 session: every open item got its automate-vs-document +call and the work landed the same night (tests green, committed). Residual: +velox itself still needs the new tlp.d radio line and a dotfiles pull — folded +into the [#A] sleep/suspend task, which works the same files on velox anyway. +Items discovered during velox setup that needed manual intervention after archsetup. +Decide which should be automated in archsetup vs documented as post-install steps. + +*** 2026-08-08 Sat @ 04:43:42 -0500 Automated radio enable via TLP (rfkill boot soft-block) +Root cause sharpened during triage: archsetup masks systemd-rfkill on laptops +(it fights TLP), so nothing restored radio state at boot — the "unblock once +should stick" premise was wrong under the mask. Fix in the TLP custom conf: +=DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"=, the TLP-native mechanism. +configure_tlp_power parametrized for tests; covered by +tests/installer-steps/test_configure_tlp_power.py. + +*** 2026-07-04 Sat @ 11:48:24 -0500 Automated /efi restrictive mount permissions in fstab generation +archsetup:2827-2836 now rewrites the /efi fstab line to =fmask=0177,dmask=0077= (idempotent), so fresh installs no longer land the world-accessible =fmask=0022,dmask=0022= default. Confirmed via the 2026-07-04 task audit. (Original velox note: default vfat mount had =fmask=0022,dmask=0022=, hand-fixed to restrictive; bootctl warned about a world-accessible random-seed file.) + +*** 2026-08-08 Sat @ 04:43:42 -0500 Automated tmp.mount mask for ZFS /tmp +New mask_tmp_mount_for_zfs, called from configure_snapshots' ZFS branch: +masks tmp.mount only when the pool actually carries a dataset mounted at +/tmp (exact match), silent no-op without zfs or without the dataset. Covered +by tests/installer-steps/test_mask_tmp_mount_for_zfs.py; the orchestrator +dispatch pin updated. + +*** 2026-08-08 Sat @ 04:43:42 -0500 Automated CPU microcode install by vendor +New install_cpu_microcode, first in boot_ux so grub-mkconfig and mkinitcpio's +microcode hook both see the installed /boot/<vendor>-ucode.img: vendor_id from +/proc/cpuinfo → intel-ucode / amd-ucode, error_warn on unknown vendor. +Covered by tests/installer-steps/test_install_cpu_microcode.py; boot_ux +sequence pin updated. + +*** 2026-07-04 Sat @ 11:48:24 -0500 Automated syncthing user-service enable in archsetup +archsetup:2263-2271 now installs syncthing and enables the user service (via symlink), so fresh installs no longer leave it installed-but-disabled. Confirmed via the 2026-07-04 task audit. (Original velox note: package installed but service not enabled; hand-fixed with =systemctl enable --now syncthing@cjennings=.) + +*** 2026-08-08 Sat @ 04:43:42 -0500 Closed the awww-daemon crash watch — no recurrence +The April boot crash never recurred across four months of daily use on both +machines (and the wallpaper stack has since been reworked). Reopen as its own +bug with fresh evidence if it ever comes back. + +*** 2026-08-08 Sat @ 04:43:42 -0500 Automated touchpad device detection in the pointer scripts +The scripts were already in stowed dotfiles with binds — the open half was the +hardcoded Framework device name. Both touchpad-auto and toggle-touchpad now +auto-detect the touchpad (first pointer named *touchpad*, pixa fallback) and +derive the internal-pointer exclusion set from the detected name, so they +agree on any machine. Test seams added (--detect / --has-external-mouse); +tests/touchpad-auto/ new, toggle-touchpad suite still green. Dotfiles commit; +velox picks it up on its next pull. + +*** 2026-08-08 Sat @ 04:43:42 -0500 Documented bluetooth pairing in the post-install checklist +Inherently interactive, so it can't ride the installer. Documented in the new +[[file:docs/post-install-checklist.org][docs/post-install-checklist.org]] along +with the Proton Bridge steps — the standing home for manual post-install work. +Consider: document as post-install step. No automation possible. + +*** 2026-05-26 Tue @ 13:32:31 -0500 pocketbook install concern moot — pulled from publication, folded in-tree +Resolved by removing pocketbook from archsetup's provisioning entirely. It's nowhere near ready, so the github mirror + cjennings.net repo were deleted and the project was folded into the archsetup tree at =pocketbook/=. Dropped the =gtk4-layer-shell= dep + =pip_install= from =archsetup= and the clone from =scripts/post-install.sh=. No fresh install pulls pocketbook now, so "not installed on velox" no longer applies. Re-wiring the install is tracked in the new pocketbook development backlog. + +*** TODO Review: Tailscale needs login after install +~tailscaled~ service was enabled but needed ~tailscale up~ for interactive auth. +Old machine entry needed cleanup in admin console. +Consider: document as post-install step. + +*** TODO Review: docs/ directories need manual sync from existing machine +docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ratio. +Same for ~/.emacs.d/docs/. Not in git, so not available after clone. +Consider: document as post-install step or create a sync script. +** DONE [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar: +CLOSED: [2026-08-08 Sat] +Closed at the 2026-08-08 task review: Craig confirms the separator work landed +a while back and the bar reads correctly now. +Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules. +Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C]. +Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule). +** CANCELLED [#C] Add a whole-display dim mode :feature:hyprland: +CLOSED: [2026-08-08 Sat] +Killed at the 2026-08-08 task review: the July auto-dim work covers the actual +need; no separate dim-everything mode wanted. +Extend auto-dim with an explicit “dim everything” setting for bright +non-dark-mode contexts, with a security/usability review of its scope. +** DONE [#C] Fix install errors surfaced by the 2026-05-11 VM test run +CLOSED: [2026-08-08 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +Closed at the 2026-08-08 task review: every archsetup-attributable error was +fixed and verified (fontconfig, dconf x2, emacs-stow, AUR exit-0 logging at +the root); the residual four reproduce unchanged and are diagnosed +environment/non-critical, with two 2026-06-28 full runs attributing zero +issues to archsetup. Residual thread: confirm the firewall nf_tables pair on +bare metal at the next real install — no container task needed to carry it. +*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set +Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread. + +*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set +The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized. +Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6. +- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=). +- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install). +- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above). +- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug. +- verifying firewall is active — exit 1 (follow-on from the firewall-enable error). +- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical. +- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?). +Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM). + +*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root +Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task. + +*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern +2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow. + +*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end +=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path. +** CANCELLED [#C] Review current tool pain points annually +CLOSED: [2026-08-08 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +Killed at the 2026-08-08 task review: an undated annual intention that never +fired — pain points get surfaced organically as they bite. +Once-yearly systematic inventory of known deficiencies and friction points in current toolset @@ -3018,6 +3018,7 @@ install_programming_languages() { # Shell pacman_install shellcheck # Shell script linter pacman_install shfmt # Shell script formatter + pacman_install bash-language-server # Bash language server; prog-shell.el warns at every start without it # Go pacman_install delve # Go programming language debugger @@ -3603,6 +3604,20 @@ EOF run_task "enabling TLP service" systemctl enable tlp.service systemctl mask systemd-rfkill.service systemd-rfkill.socket >> "$logfile" 2>&1 || \ error_warn "masking systemd-rfkill for TLP" "$?" + # Masking systemd-rfkill leaves the resume edge with no owner. TLP's own + # sleep hook runs `tlp resume`, but DEVICES_TO_ENABLE_ON_STARTUP means + # startup and TLP has no ON_RESUME, so radio state is not restored after + # a sleep cycle. WiFi survives because NetworkManager unblocks itself; + # bluetooth stays soft-blocked, and after a hibernate its controller + # comes back wedged as well. This hook closes both, and it belongs here + # rather than beside the other installs because the mask above is what + # creates the gap it fills. + # Arch does not ship /etc/systemd/system-sleep, and install_executable + # is a plain cp, so without this the install warns and leaves no hook. + mkdir -p /etc/systemd/system-sleep >> "$logfile" 2>&1 || \ + error_warn "creating /etc/systemd/system-sleep" "$?" + install_executable "$user_archsetup_dir/scripts/zz-bluetooth-resume" \ + /etc/systemd/system-sleep/zz-bluetooth-resume # power-profiles-daemon.service declares # "Conflicts=tuned.service tlp.service auto-cpufreq.service ..." (note # the direction: the line is in ppd's unit, NOT tlp's — grepping @@ -3892,10 +3907,12 @@ outro() { printf "\n" printf "If you use Proton Mail Bridge for cmail triage, finish the setup\n" printf "after reboot:\n" - printf " 1. Clone claude-templates to ~/projects/claude-templates if missing.\n" - printf " 2. Run 'protonmail-bridge --cli', log in, then quit.\n" - printf " 3. Run ~/code/archsetup/scripts/cmail-setup-finish.sh\n" - printf " 4. First mail sync: mbsync cmail && mu index\n" + printf " 1. Run 'protonmail-bridge --cli', log in, then quit.\n" + printf " 2. Run ~/code/archsetup/scripts/cmail-setup-finish.sh\n" + printf " 3. First mail sync: mbsync cmail && mu index\n" + printf "\n" + printf "Sending mail also needs cmail-action, which rulesets owns:\n" + printf "clone it to ~/code/rulesets and run 'make install'.\n" printf "\n" printf "Please reboot before working with your new workstation.\n\n" diff --git a/docs/post-install-checklist.org b/docs/post-install-checklist.org index f0545a7..8c48938 100644 --- a/docs/post-install-checklist.org +++ b/docs/post-install-checklist.org @@ -97,7 +97,12 @@ needs doing. The installer's completion message carries the steps; recorded here too so the checklist is complete: -1. Clone claude-templates to =~/projects/claude-templates= if missing. -2. Run =protonmail-bridge --cli=, log in, then quit. -3. Run =~/code/archsetup/scripts/cmail-setup-finish.sh=. -4. First mail sync: =mbsync cmail && mu index=. +1. Run =protonmail-bridge --cli=, log in, then quit. +2. Run =~/code/archsetup/scripts/cmail-setup-finish.sh=. +3. First mail sync: =mbsync cmail && mu index=. + +Sending mail also needs =cmail-action= on PATH, which rulesets owns: clone it +to =~/code/rulesets= and run =make install=. That is not a prerequisite for the +steps above — the setup script warns and carries on — but =mbsync= is the first +thing that wants it. An agent session runs =make install= at startup, so on a +machine that runs them the link appears on its own. diff --git a/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org new file mode 100644 index 0000000..d9ec8d4 --- /dev/null +++ b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org @@ -0,0 +1,213 @@ +#+TITLE: Guarded-Upgrade Completion — keeping topgrade freshness honest +#+AUTHOR: Craig Jennings +#+DATE: 2026-08-25 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Guarded-upgrade completion +:PROPERTIES: +:ID: 81cdfd72-db96-43d3-aa03-779878c99f3e +:END: +- [2026-08-25 Tue @ 18:45 -0600] decisions closed 7/7. The kernel decision reversed on the velox DKMS failure chain: held on every everyday run, landed only in the dedicated session behind a DKMS/initramfs/snapshot gate. +- [2026-08-25 Tue @ 18:30 -0600] redirected: the everyday path is a live split upgrade (apply everything the guard would not block, defer the rest); the boot-time oneshot becomes the completion step for the deferred set. Decided while running exactly that by hand on ratio. +- [2026-08-25 Tue @ 06:39:42 -0600] drafted. Grounded in a live read of the maint engine, the pacman hooks, and the boot path on velox, not memory. The topgrade-freshness diagnosis that motivates it is in this session's log. + +* Metadata + +| Status | draft | +|----------+-------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-------------------------------------------------------------| +| Related | maint =topgrade_age= metric; =hypr-live-update-guard= hook | + +* Summary + +The waybar maintenance module shows topgrade freshness as permanently stale. The cause is a real one: on a machine running Hyprland, a full =topgrade= almost never exits 0, because its system step upgrades GPU/compositor libraries that the =hypr-live-update-guard= pacman hook correctly refuses to swap under a live session. The freshness stamp is gated on topgrade's exit code, so a correct, protective refusal reads as "you never run updates." This spec designs a safe path to actually complete a guarded upgrade, and makes that completion record the freshness stamp, so the metric tracks the true state of the system. + +* Problem / Context + +The metric reads one cache key, =topgrade_run= (=~/.local/state/maint/topgrade_run.json=). Absent, the probe (=maint/src/maint/probes/updates.py:114=) returns WARN, "no topgrade run recorded". Two writers stamp it: the =topgrade= PATH wrapper (=~/.dotfiles/hyprland/.local/bin/topgrade=) on =rc -eq 0=, and the panel's TOPGRADE lever (=doctor.py=), which returns before the stamp on any non-zero exit. The read path is sound (a sandboxed =maint stamp topgrade= writes the file and =maint status= then reads freshness 0); the file is simply never written. + +It is never written because topgrade rarely exits 0 on this machine, and the reason is specific rather than flaky. =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= is a =PreTransaction=/=AbortOnFail= hook that, when Hyprland is running and an upgrade changes the on-disk version of a GPU/compositor library, prints a BLOCKED banner and exits 1 — aborting the whole transaction before any file is swapped. Its trigger set is =mesa=, =mesa-*=, =wayland=, =libdrm=, =libglvnd=, =hyprland=, =aquamarine=, =hyprutils=, =hyprgraphics=, =vulkan-radeon=, =vulkan-intel=, =vulkan-mesa-layers=, =nvidia-utils=, =lib32-nvidia-utils=, =xorg-xwayland=. The guard exists for a proven failure: replacing those libraries under a live compositor makes the next GPU call hit a now-deleted mapping and SIGABRT, taking every Wayland client down (hit on ratio 2026-06-07). + +So when any of those libraries has an update pending — a frequent event — topgrade's =system= step (it runs =yay=) aborts non-zero, topgrade returns non-zero, and neither writer stamps. The observed case: on 2026-08-24 topgrade ran at 17:49, hit the guard on =mesa= (26.1.7 → 26.2.1), and failed; the upgrade was then finished by hand with the guard's sentinel override, entirely outside the wrapper, so nothing stamped. The metric has read stale ever since. + +Two framings of the fix are in tension, and choosing between them is the spec's central decision. Either the metric means "how recently did you run the sweep" (recency), so the stamp should decouple from topgrade's exit; or it means "is the system up to date" (state), so staying stale while a guarded upgrade is deferred is *correct* and the only real defect is that safely completing that upgrade doesn't stamp. This spec takes the state framing (see Decisions). + +* Goals and Non-Goals + +** Goals +- A safe, low-friction way to apply a guarded (GPU/compositor-library) upgrade, with Hyprland not live at swap time. +- That completion records the =topgrade_run= freshness stamp, so the metric clears when the system is genuinely current. +- A boot-time upgrade path that can never lock the machine out of its session, however it fails. +- The installer owns the durable pieces so a rebuilt machine has them without hand-setup. + +** Non-Goals +- Weakening or bypassing the =hypr-live-update-guard= hook. It stays exactly as strict; this builds *around* it, not through it. +- Making the full topgrade ecosystem sweep (git repos, vim, npm, ...) run at boot. Those never need a stopped compositor and are out of the boot path. +- Changing how the kernel hazard is *guarded*. The hook stays silent on kernels; the split script holds them back on a live run as a second, separately-reasoned list (see Design), which is a deferral policy rather than a guard. +- A general offline-update system for all of pacman. Scope is the guarded-library case. + +** Scope tiers +- v1: the split-upgrade script (live: apply the non-blocked remainder, defer the rest, run the ecosystem sweep with the system step off, report the deferred set); maint's UPDATE/TOPGRADE levers route through it; an "apply on reboot" affordance that installs the held kernel live and arms the boot-time oneshot for the GPU/compositor set. +- Out of scope: full-sweep-at-boot; touching the guard's policy. +- vNext: none open — the kernel deferral that was vNext is now part of v1's held set. + +* Design + +The shape follows one principle: the only part of topgrade that needs a stopped compositor is its =system= step when a guarded library is pending. Everything else runs fine live and rarely fails. So the safe path is small and targeted — apply the guarded system upgrade with Hyprland down, once, and stamp it — while the ordinary full sweep stays a normal live =topgrade= run. + +Three pieces, at two altitudes — but the everyday gesture is not a reboot. It is a normal live update that simply leaves the dangerous few behind. + +*The split script.* A pacman =PreTransaction= hook can only abort or allow the transaction it is handed; it cannot drop targets from it. So "upgrade everything except the guarded set" cannot live in the hook — it lives one layer up, in a script the panel calls. On a live run the script: refreshes the sync db and reads the pending set (=checkupdates=); computes the *blocked set* = the guard's own trigger list (read from the installed hook's =Target= lines, so there is one source of truth, and version-aware the way the guard is — a same-version reinstall is not a swap) plus the *kernel set* (every installed kernel with its =-headers=, always as a set; held on every everyday run because a failed DKMS rebuild on velox's ZFS root leaves the machine unbootable — see the kernel decision); clears the news hook (=informant read=) where installed; runs =pacman -Syu --noconfirm --ignore=<blocked set>=; runs the AUR-only remainder (=yay -Sua --noconfirm=, AUR packages pinning a guarded version hold themselves back); then runs =topgrade --disable system,git_repos -y= so the other ecosystems still get their sweep and topgrade can actually exit 0. It writes the deferred set to a state file the panel reads, and exits 0 when the live part succeeded, whatever was deferred. The guard hook stays installed as the backstop for a bare =pacman -Syu= typed at a shell; on the driven path it never fires. Proof of concept: this exact sequence, run by hand on ratio on 2026-08-25 while Hyprland was live, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred — after one unrelated fix, an orphaned =qemu-block-gluster= that had been dropped from the repo. + +*For the user.* UPDATE and TOPGRADE on the panel run the split script; they succeed, and the panel shows "N deferred" when the script held anything back. Landing the deferred set is a dedicated session, chosen on purpose, run in the foreground from the panel's action or =guarded-upgrade --complete= in a terminal: first the kernel set, live, with the desktop still up; then the gate — every DKMS module built for the new kernel, a fresh initramfs, and on a ZFS root a pre-pacman snapshot to fall back on. If the gate fails the script stops there, names what failed, and does not reboot; the machine keeps running on the old kernel and the desktop is available for the fix. If it passes, the script arms a persistent flag for the GPU/compositor set and offers to reboot (or, from a TTY with no compositor, applies that set directly). On the next boot, before the autologin shell starts Hyprland, the deferred guarded upgrade runs in the console — the guard passes freely because nothing is live — the stamp is written, the flag is cleared, and boot continues into the session. No second reboot: the libraries are already current before anything maps them. If anything goes wrong, the machine still boots into Hyprland and the panel still shows the pending upgrade, so you are never worse off than before arming. + +*For the implementer.* A persistent arm flag (a file on a non-tmpfs path, e.g. =/var/lib/archsetup/apply-upgrade-on-boot=, so it survives the reboot the =/run= guard sentinel cannot). A system oneshot, =archsetup-boot-upgrade.service=, =ConditionPathExists= on the flag, ordered =Before=getty@tty1.service= so it completes before autologin execs Hyprland — this ordering is mandatory, because a parallel run would let Hyprland start mid-swap and reintroduce the exact crash the guard prevents. The unit is bounded (=TimeoutStartSec=) and best-effort: its failure or timeout must not fail any target the session needs, so boot proceeds past it regardless. Its =ExecStart= runs, as the user: =informant read= (clear the news hook that would otherwise abort the transaction), then =topgrade --only system= (or the equivalent =yay -Syu=), then =maint stamp topgrade= on success, then removes the flag unconditionally (a one-shot arm — a failed attempt disarms rather than retrying every boot). =sudo= works unattended (=%cjennings NOPASSWD: ALL=), so no password prompt wedges it. + +The stamp also needs to happen when the upgrade is completed by other safe means — the by-hand sentinel-override path, or a =maint= command that does the same thing. The cleanest single home for the stamp is a small =maint apply-upgrade= (or a flag in the existing lever) that performs the guarded system upgrade and stamps on success, which both the boot unit and an interactive TTY run call. That keeps one code path that "completes a guarded upgrade and records it," rather than three writers that can drift. + +* Alternatives Considered + +** A. Decouple the stamp from topgrade's exit code (stamp on any real run) +- Good, because it is a one-line change to the wrapper and needs no boot machinery. +- Bad, because it throws away honest signal: a topgrade that was blocked from applying a real upgrade would read as "fresh," so the metric stops meaning "up to date." On this machine the blocked case is the common case, so the metric would be fresh precisely when an upgrade is outstanding. +- Neutral, because the failed steps still surface elsewhere (pending-updates count), so freshness would become redundant rather than wrong. + +** B. Run the full topgrade live with the guard overridden, then reboot +- Good, because it needs no new unit — arm the sentinel, run, reboot. +- Bad, because the dangerous window is the whole rest of the run: mesa swaps early, then topgrade spends minutes on other ecosystems while the live compositor is one new GL context (a new window, the wallpaper daemon) away from SIGABRT. topgrade's own reboot-at-end is that window, not a fix for it. +- Neutral, because it would stamp naturally on success — if it survived. + +** C. Manual TTY ritual only (log out, run topgrade at the console, reboot), plus stamp +- Good, because it is the safest path and needs almost no code — just make the completion stamp. +- Bad, because it is all manual, every guarded-upgrade day; the friction is why it won't happen consistently, which is how the metric got stale in the first place. +- Neutral, because it is exactly what the boot unit automates, so it is really "v1 minus the automation." + +** D. Boot-time armed oneshot, arch-only (this spec) +- Good, because the risky swap happens with nothing live, the run is one bounded transaction with a tiny prompt surface, it stamps on success, and a failure degrades to "boots normally, try again." +- Bad, because it puts a unit on the boot critical path, which must be bounded and non-fatal with care, and it is the most to build. +- Neutral, because it composes with C: the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit. + +** E. Split the live run: apply the non-blocked remainder now, defer the rest (this spec's everyday path) +- Good, because it is what a careful operator does by hand anyway — and did, on ratio, the day this was decided. The live run succeeds on the common day, topgrade exits 0, the AUR and every other ecosystem stay current, and the guard's abort becomes the rare path rather than the default. +- Bad, because Arch calls any =--ignore= run a partial upgrade. In practice pacman still enforces declared dependencies, so anything needing the newer mesa fails resolution instead of installing broken; the residual exposure is a package with an *unversioned* dependency built against a new ABI, which for mesa/wayland/libdrm is rare. Named, accepted. +- Bad, because a deferred set nobody surfaces is a set that silently never lands — the same trap as the freshness stamp, one layer down. So the script must record the deferred set durably and the panel must show it; this is why D stays in the design as the completion step rather than being replaced. +- Neutral, because it does not change the guard at all; it changes who decides the transaction's contents. + +* Decisions [7/7] + +** DONE Metric means state, not recency +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: the stamp gate can mean "ran the sweep" or "system is current." The whole fix differs by which. +- Decision: We will keep the state meaning. Freshness stays stale while a guarded upgrade is genuinely un-applied, and the fix is to make *safe completion* stamp — not to loosen the gate. +- Consequences: easier — the metric stays trustworthy as an is-current signal, and Alternative A is off the table. Harder — completion now needs a real safe path (the rest of this spec) rather than a one-line wrapper change. + +** DONE Everyday mechanism is the split live run (Alternative E); the boot oneshot (D) completes the deferred set +CLOSED: [2026-08-25 Tue 18:30] +- Owner / by-when: Craig / 2026-08-25 +- Context: the first draft made D the primary gesture, which means every guarded-library day is a reboot day. Craig's read while watching the ratio run: when the guard would trip, the rational move is to upgrade everything *except* the guarded and kernel items, then run the rest of topgrade without the yay piece — and that logic should be a script we can keep editing, not something baked into the panel. +- Decision: I will build E as the path UPDATE and TOPGRADE always take on a live session, and keep D as the way the deferred set lands (arm + reboot). C remains the manual fallback through the same script from a TTY (no compositor → nothing blocked → a full run). B stays rejected on the live-swap risk. +- Consequences: easier — the common day is one live run that succeeds; reboots are reserved for the days the deferred set is non-empty, and even then the machine keeps working until the reboot is convenient. Harder — two lists to maintain (the guard's, read from the hook; the kernel list, owned by the script), a state file the panel must render, and the partial-upgrade caveat above to keep an eye on. + +** DONE The script lives in archsetup beside the guard, and maint calls it +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: the panel (dotfiles =maint=) and the guard (archsetup =scripts/hypr-live-update-guard=, installed to =/usr/local/bin=) live in different repos, and maint already carries its own copy of the trigger list as =[updates] guard_patterns= in the thresholds TOML. +- Decision: ship the split script in archsetup next to the guard, installed by the same installer step, reading the blocked list from the installed hook so the guard and the script can never disagree. maint's UPDATE/TOPGRADE levers change their =argv= to the script; the TOML patterns stay as the panel's *display-side* mirror (the badge that says a run will defer) and gain a test asserting they match the hook. +- Consequences: easier — one owner for "which libraries are dangerous," and a rebuilt machine gets the script with the guard. Harder — a cross-repo change (archsetup ships it, dotfiles wires it), so the rollout is two commits, archsetup first. + +** DONE What a split run stamps +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: under the state framing, a run that deferred six packages left the system *not* current, yet the sweep ran and every other ecosystem is fresh. +- Decision: the script stamps =topgrade_run= only when the deferred set is empty. When it is non-empty it writes the deferred set to its own cache key, and the panel renders that as its own state ("6 deferred — apply on reboot") rather than as stale freshness. The boot oneshot stamps when it completes the deferred set. Freshness keeps meaning "current"; the deferred badge carries the other half. +- Consequences: easier — no signal is thrown away, and the reboot nag has a precise count behind it. Harder — one more cache key and one more probe in maint. + +** DONE Kernel set is held on every everyday run and lands only in the dedicated session, gated on the DKMS result +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: I first wrote this as "install the kernel live at apply-on-reboot," on the reasoning that a kernel swap crashes nothing and the modules-vanish window ends with the reboot. Craig asked what happens on velox when the DKMS rebuild fails, and the answer changed the decision. Velox is an encrypted ZFS root with =/boot= inside the root dataset, one kernel (=linux-lts=), and =zfs-dkms=. On a kernel upgrade the DKMS build runs PostTransaction, after the kernel is swapped and the old modules are deleted, so nothing can abort; a failed build leaves a new kernel beside an initramfs built for the old one, whose =zfs.ko= won't load, and the next boot can't import the pool. It is survivable — ZFSBootMenu can boot the pre-pacman snapshot, which holds the old kernel, initramfs, and modules — but it is a recovery session, not an update. Ratio (btrfs root, two kernels, zfs only for a data pool) is exposed only at the pool. The realistic triggers are a kernel major outrunning OpenZFS's supported range, a kernel upgraded without its headers, a toolchain regression, or a full disk. +- Decision: the script holds the kernel set — every installed kernel with its =-headers=, moved as a set, never one without the other — on every everyday run, on both machines, so there is one rule rather than a per-host exception. The kernel set lands only in the dedicated session, live, while a working desktop exists for diagnosing, and the script gates what follows on the result: =dkms status= reports every DKMS module installed for the new kernel version, the initramfs is newer than the kernel image, and on a ZFS root a pre-pacman snapshot exists. A failed gate stops with the failure named and never reboots. The GPU/compositor set follows only after the gate passes — armed for the boot oneshot, or applied from a TTY. Kernels stay off the guard's list (the hook would block a TTY kernel upgrade for no reason). "Install the kernel live at apply-on-reboot" is withdrawn. +- Consequences: easier — an everyday UPDATE can never put velox into the unbootable state, and the day the kernel moves is one Craig chose, sitting at the machine, expecting to handle issues. Harder — the kernel deferral is now standing, so the dedicated session has to happen on a cadence (security fixes ride the kernel), and the panel's deferred count carries a kernel most days; the gate is one more script to test, with fakes for =dkms status= and the image timestamps. + +** DONE Boot run applies exactly the deferred GPU/compositor set, nothing else +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: the only packages that need a stopped compositor are the guard's trigger set; the rest run fine live and are what usually fail. The first draft phrased this as =topgrade --only system=; with the split script that wording is stale, and with the kernel decision above the kernel is not part of what boot applies either. +- Decision: the boot oneshot runs the script's =--complete= form scoped to the deferred GPU/compositor set: one pacman transaction, no ecosystem sweep, no kernel. The full topgrade sweep stays a normal live run through the everyday path. +- Consequences: easier — the boot path is fast, has a tiny interactive-prompt surface, and rarely fails. Harder — freshness after a boot run reflects the guarded set specifically, which is what the stamp decision above already accounts for. + +** DONE Arm flag lives on a persistent path and is one-shot +CLOSED: [2026-08-25 Tue 18:45] +- Owner / by-when: Craig / 2026-08-25 +- Context: the guard's =/run= sentinel is tmpfs and cleared on reboot, so it cannot carry an intent across the reboot. A boot that retries forever on failure is its own outage. +- Decision: We will use a persistent flag (=/var/lib/archsetup/=) that the boot unit removes unconditionally at the end of its attempt — success or failure disarms. +- Consequences: easier — the intent survives exactly one reboot and a failed attempt never wedges subsequent boots. Harder — a failed attempt needs re-arming, which is correct (a human decides to try again) but is a manual step. + +* Implementation phases + +** Phase 1 — The split-upgrade script (archsetup) +=scripts/guarded-upgrade= (name open), installed to =/usr/local/bin= by the step that installs the guard. Behaviour as in Design: pending set → blocked set (hook =Target= lines, version-aware) ∪ held-kernel set when a compositor is live → =informant read= if present → =pacman -Syu --noconfirm --ignore=…= → =yay -Sua --noconfirm= → =topgrade --disable system,git_repos -y= → deferred set written to a state file → stamp only when nothing was deferred → exit 0 on a successful live part. The kernel set is derived from what is installed (every =linux*= kernel package and its =-headers=), never a hardcoded pair, and is always held or applied whole. Flags: =--dry-run= (print the plan and the deferred set, change nothing), =--no-topgrade=, =--no-aur=, =--complete= (the dedicated-session form: apply the kernel set live, run the gate, then arm the GPU/compositor set or, with no compositor live, apply it directly; stamp when the deferred set is empty). The gate is its own small script, =kernel-modules-check=: for each kernel under =/usr/lib/modules=, =dkms status= reports every registered module =installed= for it, and its initramfs is newer than its =vmlinuz=; on a ZFS root, a =pre-pacman_= snapshot of the root dataset exists. It exits non-zero with the failing item named, and =--complete= refuses to arm or reboot on that exit. Usable from a TTY at once. Tests (pytest beside the guard's): blocked-set computation against a fixture hook and version map; the kernel set is derived from the installed kernels and held whole on every everyday run; the =--ignore= list is exactly blocked ∪ kernel set; the state file round-trips; stamps only on an empty deferred set; =--dry-run= is IO-free; the gate passes and fails on fake =dkms status= output, image timestamps, and snapshot listings, and =--complete= never reaches the arm step on a failed gate. + +** Phase 2 — Wire maint to it (dotfiles) +UPDATE and TOPGRADE levers change their =argv= to the script; the press-again-to-force sentinel wrap goes away (the driven path never trips the guard). A new probe reads the deferred-set state file and the panel renders "N deferred — apply on reboot" as its own row. A test asserts the TOML =guard_patterns= equal the installed hook's =Target= list. Tests under the maint fake harness. + +** Phase 3 — "Apply on reboot" and the boot-time unit (archsetup + maint) +The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot. =archsetup-boot-upgrade.service=, installed by the installer: =ConditionPathExists= the flag, =Before=getty@tty1.service=, =TimeoutStartSec= bounded, non-fatal to every session target, =ExecStart= runs the script's =--complete= form as the user and removes the flag unconditionally. Installer step + unit file + the =/var/lib/archsetup/= flag directory. Tests in =tests/installer-steps/= for the install step; the arm action's tests in maint; a documented manual boot test (defer, arm, reboot, observe) in =todo.org= under Manual testing and validation. + +** Phase 4 — Docs, rollout, and both daily drivers +Document the flow (arm → reboot → console upgrade → session). Roll the unit to velox and ratio (installer already covers a rebuild; existing machines need the one-time install). Confirm the ratio path matches. + +* Acceptance criteria +- [ ] With a guarded library pending and Hyprland live, UPDATE applies everything else, exits 0, and the panel shows the exact deferred set; the guard hook does not fire. +- [ ] The same run with no compositor live (a TTY) applies everything but the kernel set and stamps only if nothing was deferred. +- [ ] A =--complete= run whose DKMS build fails stops before arming or rebooting, names the failure, and leaves the machine running on the old kernel; on velox the pre-pacman snapshot it required is bootable from ZFSBootMenu. +- [ ] With a guarded library pending, arming and rebooting applies it in the console before Hyprland starts, and =maint status= then reads a fresh =topgrade_age=. +- [ ] A boot-upgrade failure (a failed step, a timeout, an aborted transaction) never blocks the session: the machine boots into Hyprland, the flag is cleared, and the panel still shows the pending work. +- [ ] Unread Arch news does not wedge the boot run (=informant read= precedes the transaction). +- [ ] A guarded upgrade completed from a TTY via the Phase-1 path stamps freshness identically to the boot unit. +- [ ] The =hypr-live-update-guard= hook is unchanged and still blocks a live guarded swap. + +* Readiness dimensions +Answer each, or write "N/A because…". +- Data model & ownership: the arm flag (=/var/lib/archsetup/=, installer-owned) and the =topgrade_run= cache key (maint-owned). No user-authored data. +- Errors, empty states & failure: the boot unit is best-effort and self-disarming; every failure path lands in "boot normally, metric stays stale, re-arm to retry." Named, non-silent. +- Security & privacy: relies on the existing =%cjennings NOPASSWD: ALL=; the unit runs the upgrade as the user via sudo, adds no new privilege. Note the NOPASSWD breadth as a pre-existing fact, not introduced here. +- Observability: the boot run's output is on the console; its systemd unit status and journal record success/failure; the panel reflects the cleared or still-pending state after boot. +- Performance & scale: one pacman/yay transaction at boot; bounded by =TimeoutStartSec=. Negligible boot-time cost when the flag is absent (=ConditionPathExists= skips the unit). +- Reuse & lost opportunities: reuses the guard's trigger list by reading the installed hook (single source of truth for "which libs are dangerous"), =informant=, =maint stamp=, =checkupdates=, and topgrade's own step switches. The one duplicate that exists today — maint's TOML =guard_patterns= — is kept as a display mirror and pinned to the hook by a test rather than removed. +- Architecture fit & weak points: integration points are the pacman hook set, getty autologin ordering, and the maint cache. Weak point: the =Before=getty@tty1= ordering is load-bearing for safety; a parallel run reintroduces the live-swap crash. Mitigated by making the ordering explicit and tested-by-inspection. +- Config surface: the arm flag path and the timeout. Defaults safe (absent flag = no-op). +- Documentation plan: a short "reboot to apply guarded upgrades" note in the maint docs; the installer step self-documents in-comment. +- Dev tooling: installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3; a manual boot test in =todo.org=. +- Rollout, compatibility & rollback: additive; removing the unit and flag reverts fully. Existing machines need a one-time install; a rebuild gets it from the installer. Rollback leaves the guard and manual TTY path intact. +- External APIs & deps: topgrade =--only system=, =informant read=, =yay=, =maint stamp= — all verified present on velox this session. No external service. + +* Risks, Rabbit Holes, and Drawbacks +- Boot critical path: the unit sits ahead of autologin, so a hang would delay boot. Mitigated by =TimeoutStartSec= and non-fatal wiring; worst case is a bounded delay, then a normal session. +- Interactive prompts under no stdin: =yay=/pacman can still prompt (provider choice, replace, AUR review) even with =assume_yes=. The =--only system= scope and =--noconfirm=-style flags shrink this to near zero, but a prompt with no stdin fails the run (benign) — needs a genuinely non-interactive invocation, verified in Phase 2. +- Partial ecosystem state: N/A for the GPU hazard — each pacman run is one atomic transaction, so there is no half-swapped library. The =--ignore= run is a partial upgrade in Arch's sense; pacman's dependency resolution is the safety net, and the residual unversioned-ABI exposure is accepted in Alternative E. +- Orphans that block resolution: a package dropped from the repo but still pinning an old version (ratio's =qemu-block-gluster= on 2026-08-25) fails the whole transaction. The script should detect the "could not satisfy dependencies" case, name the foreign package, and stop with the remedy — never =-Rdd= on its own. +- The kernel on a DKMS ZFS root: a failed =zfs-dkms= build after the kernel swap cannot be aborted (the DKMS hooks are PostTransaction) and leaves velox unbootable on the new kernel. Mitigated by holding the kernel set on every everyday run, landing it only in the dedicated session behind the gate, and by the standing fallback: =/boot= lives in the root dataset, the =05-zfs-snapshot= hook snapshots it before every transaction, and ZFSBootMenu can boot that snapshot. The pacman cache also keeps the previous kernel and =zfs-dkms= for a downgrade. Ratio's exposure is its data pool only (btrfs root, two kernels). +- Standing kernel deferral: because the everyday run never moves the kernel, the dedicated session has to happen on a cadence or kernel security fixes sit unapplied. The panel's deferred row is the reminder; a stale-kernel age in maint is a possible follow-up. + +* Testing / Verification / Rollout +Phase-1 and Phase-3 logic under the maint fake harness; Phase-2 install under =tests/installer-steps/=. The one thing no unit test can cover — that an armed reboot actually applies the upgrade pre-session and stamps — is a scripted manual test in =todo.org= (arm with a guarded lib pending, reboot, confirm the console run, the fresh metric, and a normal session). Roll to velox first, then ratio. + +* Review and iteration history +** 2026-08-25 Tue @ 18:45 -0600 — Craig Jennings — author +- What: closed all seven decisions. Reversed the kernel decision (hold on every everyday run; land only in the dedicated session, gated on DKMS built, initramfs fresh, snapshot present; withdrew "install live at apply-on-reboot"), reworded the boot-scope decision for the split design, added the =kernel-modules-check= gate to Phase 1 and the acceptance criteria, and wrote the velox failure chain and the ZFSBootMenu fallback into Risks. +- Why: on velox a failed =zfs-dkms= rebuild after a kernel swap is unabortable and unbootable; that belongs in a session I chose, not in an update I expected to touch applications. +- Artifacts: this session's log (velox boot layout verified live: ZBM on the ESP, =/boot= in =zroot/ROOT/default=, one kernel, =zfs-dkms 2.4.4=). +** 2026-08-25 Tue @ 18:30 -0600 — Craig Jennings — author +- What: made the split live run (E) the everyday path and the boot oneshot (D) the completion step; added the script-ownership, stamp-semantics, and kernel-hold decisions; rewrote the phases around the script; added the orphan-blocks-resolution risk. +- Why: watching a 724-of-730 guarded run succeed by hand on ratio made it obvious the guard's abort should be the rare path, and that the logic belongs in an editable script the panel calls rather than in the panel. +- Artifacts: this session's log; the ratio run (=ratio-upgrade.service=, =/var/log/ratio-upgrade.log=). +** 2026-08-25 Tue @ 06:39:42 -0600 — Craig Jennings — author +- What: initial draft. +- Why: the topgrade-freshness metric reads permanently stale because the guard blocks the arch step; designing a safe completion path rather than loosening the gate. +- Artifacts: this session's log; =hypr-live-update-guard= hook; maint =topgrade_age= probe. diff --git a/scripts/cmail-setup-finish.sh b/scripts/cmail-setup-finish.sh index 949023f..8c27eda 100755 --- a/scripts/cmail-setup-finish.sh +++ b/scripts/cmail-setup-finish.sh @@ -1,32 +1,37 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-or-later -# cmail-setup-finish.sh — finish Proton Mail Bridge + cmail-action setup after -# Bridge first-run. Idempotent; safe to re-run after a Bridge cert rotation or -# a claude-templates re-clone. +# cmail-setup-finish.sh — finish Proton Mail Bridge setup after Bridge +# first-run. Idempotent; safe to re-run after a Bridge cert rotation. # # Pre-reqs (the script aborts if any are missing): # - protonmail-bridge installed (archsetup handles it) # - You have run 'protonmail-bridge --cli', logged in, and quit at least once # (the script looks for state at ~/.config/protonmail/bridge-v3/) -# - claude-templates cloned at ~/projects/claude-templates # - dotfiles stowed (~/.config/.cmailpass.gpg present) # +# Not a pre-req, but checked and warned about: cmail-action on PATH. rulesets' +# `make install` links it, and session start runs that, so on a machine that +# runs agent sessions it arrives without anyone asking. On one that doesn't, +# it needs the command by hand. The script never invokes it either way. +# # What it does: # 1. Decrypts ~/.config/.cmailpass.gpg → ~/.config/.cmailpass (mode 0600) # 2. Copies Bridge's self-signed cert → ~/.config/protonbridge.pem -# 3. Symlinks ~/projects/claude-templates/.ai/scripts/cmail-action.py -# → ~/.local/bin/cmail-action -# 4. Removes the leftover ~/.config/autostart/Proton Mail Bridge.desktop +# 3. Removes the leftover ~/.config/autostart/Proton Mail Bridge.desktop # stub (it double-launches Bridge alongside the systemd user service # and throws an "orphan instance" dialog every login) -# 5. Installs a wait-for-dns drop-in so Bridge doesn't spam +# 4. Installs a wait-for-dns drop-in so Bridge doesn't spam # name-resolution errors during the early-boot DNS race -# 6. Enables + starts the protonmail-bridge user service -# 7. Verifies Bridge is listening on 127.0.0.1:1143 / :1025 +# 5. Enables + starts the protonmail-bridge user service +# 6. Verifies Bridge is listening on 127.0.0.1:1143 / :1025 +# +# It no longer installs cmail-action. That moved to rulesets +# (claude-templates/bin/), whose `make install` owns the symlink. set -euo pipefail err() { printf 'error: %s\n' "$*" >&2; exit 1; } +warn() { printf 'warning: %s\n' "$*" >&2; } info() { printf '==> %s\n' "$*"; } ok() { printf ' %s\n' "$*"; } @@ -47,9 +52,20 @@ bridge_state="$HOME/.config/protonmail/bridge-v3" [ -d "$bridge_state" ] \ || err "Bridge has no state at $bridge_state — run 'protonmail-bridge --cli' and log in first" -cmail_action_src="$HOME/projects/claude-templates/.ai/scripts/cmail-action.py" -[ -f "$cmail_action_src" ] \ - || err "cmail-action.py not found at $cmail_action_src — clone claude-templates first" +# cmail-action is no longer this script's to install. It lives in rulesets at +# claude-templates/bin/, and rulesets' `make install` links everything there +# into ~/.local/bin. Session start runs that, so on a machine that runs agent +# sessions the symlink arrives on its own; on one that doesn't, it needs the +# command below. +# +# A warning rather than an abort, because this script never invokes the tool. +# Its job is to leave Bridge working, and it can finish that whether or not a +# mail client has been linked yet. Aborting here would make Bridge setup +# depend on rulesets being cloned and installed first, an ordering neither +# repo otherwise needs, and would strand a fresh machine with Bridge ready and +# the script refusing to configure it. +command -v cmail-action >/dev/null 2>&1 \ + || warn "cmail-action not on PATH — run 'make -C ~/code/rulesets install' before sending mail" cmailpass_enc="$HOME/.config/.cmailpass.gpg" [ -f "$cmailpass_enc" ] \ @@ -69,13 +85,7 @@ cert_dst="$HOME/.config/protonbridge.pem" cp "$cert_src" "$cert_dst" ok "copied $cert_src → $cert_dst" -# 4. Symlink cmail-action -info "symlinking cmail-action" -mkdir -p "$HOME/.local/bin" -ln -sf "$cmail_action_src" "$HOME/.local/bin/cmail-action" -ok "linked $HOME/.local/bin/cmail-action → $cmail_action_src" - -# 5. Remove leftover XDG autostart stub +# 4. Remove leftover XDG autostart stub # The systemd --user service is the canonical launcher. The autostart .desktop # starts a second Bridge instance that can't get the lock and pops up an # "orphan instance" dialog every login. @@ -88,7 +98,7 @@ else ok "no autostart stub present" fi -# 6. Install wait-for-dns drop-in +# 5. Install wait-for-dns drop-in # User-instance systemd doesn't carry network-online.target / nss-lookup.target, # so the packaged unit's After=network.target doesn't imply DNS readiness. # Bridge starts before the resolver is up and its first API calls all fail @@ -107,7 +117,7 @@ ok "wrote $dropin_file" systemctl --user daemon-reload ok "reloaded systemd user units" -# 7. Enable + start systemd user service +# 6. Enable + start systemd user service info "enabling protonmail-bridge user service" was_active=0 systemctl --user is-active --quiet protonmail-bridge.service && was_active=1 @@ -119,7 +129,7 @@ else ok "service active" fi -# 8. Verify +# 7. Verify info "verifying Bridge is listening" listening="$(ss -ltn 2>/dev/null || true)" missing="" diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check index 2013b26..aa7ef83 100755 --- a/scripts/post-rebuild-check +++ b/scripts/post-rebuild-check @@ -18,9 +18,11 @@ # 4. gitignore-mode projects missing tooling paths their own .gitignore # names (a reinstall drops every such project's untracked working # state -- 374 files in .emacs.d's case -- and nothing carries it) -# 5. signal-cli holds no registered account (velox lost its -# registration, and because agent-text relays into this machine, -# that silently broke paging for the WHOLE fleet) +# 5. signal-cli holds no registered account (velox lost its registration +# in the rebuild; at the time agent-text relayed into velox, so that +# silently broke paging for the WHOLE fleet. agent-text now walks +# AGENT_TEXT_RELAYS in order and skips itself, so an unregistered +# machine is only fatal when no relay host is registered either) # 6. every NTP source is named by hostname (a wrong clock fails the # DoT/DNSSEC validation this machine's DNS runs on, so nothing # resolves -- including the NTP pool that would fix the clock; velox @@ -70,6 +72,15 @@ # running, the special value MISSING = not installed # PRC_REPO_REMOTES newline list of "path origin-url"; an empty URL # means origin could not be read +# PRC_UNITS_EXPECTED_DISABLED +# newline list of units whose not-enabled state is +# deliberate here, replacing the file below +# PRC_UNITS_EXPECTED_DISABLED_FILE +# path to that list (default: +# $XDG_CONFIG_HOME/post-rebuild-check/units-expected-disabled). +# One unit per line, # starts a comment. Machine-local +# on purpose: the same unit is correctly enabled on one +# box and not another # PRC_SYSTEMCTL path to the systemctl binary (a fake, under test) # PRC_SYSTEMCTL_TIMEOUT seconds to allow each systemctl call (default 5) # @@ -207,6 +218,39 @@ done < "$STAGE" report "check 1/8: failed units" # --- 2. user unit files present but not enabled --------------------------- +# +# Units nothing intends to enable here are read from a machine-local list. +# "Enabled" is this check's proxy for "will actually run", and the proxy is +# wrong for a unit nobody means to enable on this box. velox carries four, for +# four different reasons: geoclue-agent is redundant because hyprland's +# exec-once starts the binary directly, emacs is started on demand by +# emacsclient, obs-record-watchdog only matters while recording, and +# obsbot-wb-guard needs an OBSBOT the machine does not have. Left unexempted +# they report at every run, and four permanent lines in front of every real one +# teach you to skim the output -- the same argument check 4 makes about +# CLAUDE.md. +# +# Machine-local rather than a marker in the shared unit file, because +# obsbot-wb-guard is correctly ENABLED on ratio. One unit, a different right +# answer per machine, so the shared file cannot hold the answer. +# +# An entry that turns out to be enabled after all is still a finding. Without +# that the list rots into somewhere real findings go to die, which is worse +# than the noise it removes. + +EXPECT_DISABLED_FILE="${PRC_UNITS_EXPECTED_DISABLED_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/post-rebuild-check/units-expected-disabled}" +if [ -n "${PRC_UNITS_EXPECTED_DISABLED+set}" ]; then + expect_disabled=$PRC_UNITS_EXPECTED_DISABLED +elif [ -f "$EXPECT_DISABLED_FILE" ]; then + expect_disabled=$(cat "$EXPECT_DISABLED_FILE" 2>/dev/null) +else + expect_disabled="" +fi +# Strip comments and blanks once, here, so the membership test below is a +# plain word match. The reason a unit is exempt is the most useful thing about +# the entry, so the format has to carry one. +expect_disabled=$(printf '%s\n' "$expect_disabled" \ + | sed 's/#.*//' | awk 'NF {print $1}') if [ -n "${PRC_UNIT_STATES+set}" ]; then states=$PRC_UNIT_STATES @@ -292,8 +336,34 @@ while read -r name state; do esac ;; esac + # Deliberately not enabled on this machine. Checked last, so it suppresses + # only this finding and never the dangling-link one decided above on the + # filesystem. + case " +$expect_disabled +" in + *" +$name +"*) continue ;; + esac finding "unit file present but not enabled: $name ($state)" done < "$STAGE" +# The exemption list, checked in the other direction. An entry whose unit is +# enabled after all suppresses nothing, and leaving it there is how the list +# turns into a place real findings go to die. The loop above cannot catch this: +# it skips any state that is not disabled or linked, so an enabled unit never +# reaches it. +printf '%s\n' "$expect_disabled" > "$WORK/expect" 2>/dev/null || { + echo "post-rebuild-check: cannot write $WORK/expect" >&2 + echo " nothing was checked; this is not a pass" >&2; exit 1; } +while IFS= read -r name; do + [ -n "$name" ] || continue + estate=$(awk -v u="$name" '$1 == u {print $2; exit}' "$WORK/states") + case "$estate" in + enabled|enabled-runtime) + finding "$name is listed as expected-disabled but is $estate — drop the stale exemption" ;; + esac +done < "$WORK/expect" report "check 2/8: unit files" # --- 3. *.example files whose real sibling is missing --------------------- @@ -358,6 +428,16 @@ printf '%s\n' "$projects" > "$WORK/projects" 2>/dev/null || { # steady state rather than reinstall drift, and flagging it would put nine # standing findings in front of every real one. # +# .claude/ is absent for the same reason and proven the same way. The +# bootstrap and the gitignore sweep write it into the ignore set of every +# gitignore-mode project whether or not one ever exists there, so the entry is +# aspirational rather than a promise -- pearl, rsyncshot and yt-sync each name +# it and none of the three has ever had one, on velox or on ratio. Dropping it +# loses no real signal either: a project that genuinely carries a .claude/ +# (rules and hooks from a language bundle) has it re-synced by +# sync-language-bundle.sh at every session start, so a true absence heals +# itself before this check would run. +# # The list is fed to the inner loop straight from a heredoc rather than # staged through a file. It is a constant, so a file bought nothing and cost # a fifth unguarded write: had it failed (a full tmpfs, say) the inner loop @@ -388,7 +468,6 @@ while IFS= read -r proj; do esac done <<'EOF' .ai \.ai -.claude \.claude todo.org todo\.org inbox inbox EOF @@ -418,7 +497,7 @@ fi if [ "$signal_missing" = 1 ]; then finding "signal-cli is not installed — paging relies on it fleet-wide" elif [ -z "$signal_missing" ] && [ -z "$accounts" ]; then - finding "no signal account registered — agent-text relays into this machine, so paging breaks for the whole fleet" + finding "no signal account registered — this machine can only page by relaying to one that has an account; if no host in AGENT_TEXT_RELAYS is registered either, the whole fleet loses paging" fi report "check 5/8: signal registration" diff --git a/scripts/zz-bluetooth-resume b/scripts/zz-bluetooth-resume new file mode 100755 index 0000000..4273339 --- /dev/null +++ b/scripts/zz-bluetooth-resume @@ -0,0 +1,86 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-or-later +# zz-bluetooth-resume - put bluetooth back after a sleep cycle. +# +# A systemd-sleep hook. Two things break bluetooth across sleep on a TLP +# laptop, and nothing else on the machine fixes either one. +# +# 1. The rfkill soft-block is not restored. systemd-rfkill would do it, and +# it is masked here deliberately -- it fights TLP's radio handling, so +# configure_tlp_power masks it and TLP owns radios instead. TLP's own +# sleep hook runs `tlp resume`, but its setting is +# DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. TLP has no ON_RESUME +# at all, so the resume edge has no owner. WiFi survives only because +# NetworkManager unblocks itself; bluetooth has no equivalent. +# +# 2. The controller comes back wedged from a hibernate. It reports powered +# and unblocked while scanning finds nothing whatever -- zero devices +# where the same room gave seventeen a minute later -- and bluetoothd +# logs "Failed to set mode" and "Failed to add device <mac>" at the +# instant of resume. Reloading btusb clears it. +# +# Both observed on velox 2026-08-21, on the first suspend-then-hibernate cycle +# after hibernate was switched back on. The second symptom is why unblocking +# alone is not enough: rfkill was cleared by hand and scanning still returned +# nothing until the driver was reloaded. +# +# The hook re-asserts TLP's own declared intent rather than inventing a policy. +# A machine whose TLP config does not ask for bluetooth keeps it off, which is +# what stops this from overriding a deliberate block at every wakeup. +# +# The zz- prefix orders it after TLP's own hook, so `tlp resume` has finished +# before this runs. +# +# Test seams: BTR_RFKILL, BTR_MODPROBE, BTR_TLP_CONF, BTR_TLP_CONF_DIR, +# BTR_SETTLE (seconds to wait between driver unload and load). + +set -u + +RFKILL="${BTR_RFKILL:-rfkill}" +MODPROBE="${BTR_MODPROBE:-modprobe}" +TLP_CONF="${BTR_TLP_CONF:-/etc/tlp.conf}" +TLP_CONF_DIR="${BTR_TLP_CONF_DIR:-/etc/tlp.d}" +SETTLE="${BTR_SETTLE:-1}" + +# post only. The pre phase has nothing to do, and acting there would fight the +# suspend it is about to run. +[ "${1:-}" = "post" ] || exit 0 + +# Does TLP ask for bluetooth on this machine? Comments are stripped first, so a +# commented-out example in the stock config cannot be read as a policy. Both +# the main file and any drop-in count, and the last assignment wins the same +# way TLP itself resolves them. +wants_bluetooth() { + cat "$TLP_CONF" "$TLP_CONF_DIR"/*.conf 2>/dev/null \ + | sed 's/#.*//' \ + | awk -F= '/DEVICES_TO_ENABLE_ON_STARTUP/ { v = $2 } END { print v }' \ + | tr -d '"' \ + | tr ' ' '\n' \ + | grep -qx "bluetooth" +} + +wants_bluetooth || exit 0 + +# The wedge follows a hibernate, which reinitialises the controller from a +# saved image. A plain suspend brings USB back intact, so reloading there would +# tear down a working adapter for nothing. +# +# suspend-then-hibernate reports that name whether or not it reached the +# hibernate stage, so this reloads on a cycle that only suspended. That is the +# cheap side of the trade: a couple of seconds against an adapter that answers +# nothing until someone notices and reloads it by hand. +case "${2:-}" in + hibernate|suspend-then-hibernate) + "$MODPROBE" -r btusb 2>/dev/null || true + [ "$SETTLE" = "0" ] || sleep "$SETTLE" + "$MODPROBE" btusb 2>/dev/null || true + ;; +esac + +# After the reload, not before: a freshly loaded btusb can come up soft-blocked +# and would undo an earlier unblock. +"$RFKILL" unblock bluetooth 2>/dev/null || true + +# Never fail. systemd-sleep logs a failing hook, and that noise outlives the +# cause it describes; nothing here is worth alarming a resume over. +exit 0 diff --git a/tests/bluetooth-resume/test_bluetooth_resume.py b/tests/bluetooth-resume/test_bluetooth_resume.py new file mode 100644 index 0000000..6d8ed87 --- /dev/null +++ b/tests/bluetooth-resume/test_bluetooth_resume.py @@ -0,0 +1,139 @@ +"""Tests for scripts/zz-bluetooth-resume. + +Two things break bluetooth across a sleep cycle on a TLP laptop, and nothing +else on the machine fixes either. + +The rfkill soft-block is not restored. systemd-rfkill would do it, but it is +masked deliberately -- it fights TLP's radio handling, so TLP owns radios +instead. TLP's own sleep hook runs `tlp resume`, and its setting is +DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. There is no ON_RESUME in +TLP's vocabulary, so the resume edge has no owner at all. WiFi survives only +because NetworkManager unblocks itself; bluetooth has no equivalent. + +The controller also comes back wedged from a hibernate. It reports powered and +unblocked while scanning finds nothing whatever -- zero devices where the same +room gave seventeen a minute later. bluetoothd logs "Failed to set mode" and +"Failed to add device <mac>" at the instant of resume. Reloading btusb clears +it. + +Both observed on velox 2026-08-21, on its first suspend-then-hibernate cycle +after hibernate was switched back on. + +The hook re-asserts TLP's own declared intent rather than inventing a policy, +so a machine that deliberately blocks bluetooth keeps it blocked. + +Run from repo root: + python3 -m unittest tests.bluetooth-resume.test_bluetooth_resume +""" + +import os +import stat +import subprocess +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +HOOK = os.path.join(REPO_ROOT, "scripts", "zz-bluetooth-resume") + +TLP_WANTS_BT = 'DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"\n' +TLP_WIFI_ONLY = 'DEVICES_TO_ENABLE_ON_STARTUP="wifi"\n' + + +def run(phase="post", kind="suspend-then-hibernate", tlp_conf=TLP_WANTS_BT, + conf_present=True): + """Drive the hook with rfkill and modprobe faked, and read back the calls.""" + with tempfile.TemporaryDirectory() as d: + calls = os.path.join(d, "calls.log") + bindir = os.path.join(d, "bin") + os.makedirs(bindir) + for tool in ("rfkill", "modprobe"): + p = os.path.join(bindir, tool) + with open(p, "w") as fh: + fh.write(f'#!/bin/sh\necho "{tool} $*" >> "{calls}"\nexit 0\n') + os.chmod(p, 0o755) + conf = os.path.join(d, "tlp.conf") + if conf_present: + with open(conf, "w") as fh: + fh.write(tlp_conf) + env = dict(os.environ) + env.update({ + "BTR_RFKILL": os.path.join(bindir, "rfkill"), + "BTR_MODPROBE": os.path.join(bindir, "modprobe"), + "BTR_TLP_CONF": conf, + "BTR_TLP_CONF_DIR": os.path.join(d, "tlp.d"), + "BTR_SETTLE": "0", + }) + r = subprocess.run(["sh", HOOK, phase, kind], env=env, + capture_output=True, text=True, timeout=20) + log = "" + if os.path.exists(calls): + with open(calls) as fh: + log = fh.read() + return r, log + + +class BluetoothResume(unittest.TestCase): + # --- Normal --------------------------------------------------------- + def test_hibernate_reloads_the_driver_and_unblocks(self): + _, log = run(kind="suspend-then-hibernate") + self.assertIn("modprobe -r btusb", log) + self.assertIn("modprobe btusb", log) + self.assertIn("rfkill unblock bluetooth", log) + + def test_the_unblock_comes_after_the_reload(self): + # A freshly loaded btusb can come up soft-blocked, so unblocking first + # would be undone by the reload that follows it. + _, log = run() + self.assertLess(log.index("modprobe btusb"), + log.index("rfkill unblock")) + + def test_plain_suspend_unblocks_without_reloading(self): + # The wedge was seen coming out of hibernate, which reinitialises the + # controller from a saved image. A plain suspend restores USB intact, + # so reloading there would cost a working adapter for nothing. + _, log = run(kind="suspend") + self.assertIn("rfkill unblock bluetooth", log) + self.assertNotIn("btusb", log) + + # --- Boundary ------------------------------------------------------- + def test_the_pre_phase_does_nothing(self): + _, log = run(phase="pre") + self.assertEqual(log, "") + + def test_a_tlp_policy_without_bluetooth_is_left_alone(self): + # The hook re-asserts TLP's stated intent. It must not invent one, or + # a machine that deliberately keeps bluetooth off gets it turned on at + # every wakeup. + _, log = run(tlp_conf=TLP_WIFI_ONLY) + self.assertEqual(log, "") + + def test_a_commented_out_policy_does_not_count(self): + _, log = run(tlp_conf='#DEVICES_TO_ENABLE_ON_STARTUP="bluetooth"\n') + self.assertEqual(log, "") + + def test_hibernate_proper_also_reloads(self): + _, log = run(kind="hibernate") + self.assertIn("modprobe -r btusb", log) + + # --- Error ---------------------------------------------------------- + def test_a_missing_tlp_config_is_left_alone(self): + # No declared policy means no intent to re-assert. Failing safe here + # means doing nothing, not guessing. + _, log = run(conf_present=False) + self.assertEqual(log, "") + + def test_the_hook_always_exits_zero(self): + # systemd-sleep logs a failing hook and the noise outlives the cause. + # Nothing here is worth delaying or alarming a resume over. + for kind in ("suspend", "hibernate", "suspend-then-hibernate"): + with self.subTest(kind=kind): + r, _ = run(kind=kind) + self.assertEqual(r.returncode, 0, r.stderr) + + def test_it_is_executable(self): + self.assertTrue(os.stat(HOOK).st_mode & stat.S_IXUSR, + "systemd-sleep only runs executables") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py index 9902299..bc887c6 100644 --- a/tests/post-rebuild-check/test_post_rebuild_check.py +++ b/tests/post-rebuild-check/test_post_rebuild_check.py @@ -31,6 +31,9 @@ probe"): running; "MISSING" = not installed on this machine) PRC_REPO_REMOTES newline list of "path<space>origin-url" for the push-capability check ("" = no repos to check) + PRC_UNITS_EXPECTED_DISABLED + newline list of units whose not-enabled state is + deliberate on this machine ("" = no exemptions) Run from repo root: python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check @@ -51,7 +54,8 @@ CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check") def run_check(failed_units="", unit_states="", local_roots="", project_roots="", signal_accounts="+15045551234", ntp_sources="162.159.200.1\npool.ntp.org", - idle_daemon="4242", repo_remotes=""): + idle_daemon="4242", repo_remotes="", + units_expected_disabled=""): """Run the script with every probe stubbed; defaults are all-clean. Roots are newline-separated. Empty means "the seam is set and names no @@ -67,6 +71,7 @@ def run_check(failed_units="", unit_states="", local_roots="", env["PRC_NTP_SOURCES"] = ntp_sources env["PRC_IDLE_DAEMON"] = idle_daemon env["PRC_REPO_REMOTES"] = repo_remotes + env["PRC_UNITS_EXPECTED_DISABLED"] = units_expected_disabled return subprocess.run( ["sh", CHECK], capture_output=True, text=True, timeout=30, env=env, ) @@ -165,6 +170,7 @@ class NtpBootstrap(unittest.TestCase): "PRC_SIGNAL_ACCOUNTS": "+15045551234", "PRC_IDLE_DAEMON": "4242", "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "", "PRC_CHRONY_CONF": chrony_conf}) env.pop("PRC_NTP_SOURCES", None) return subprocess.run(["sh", CHECK], capture_output=True, text=True, @@ -281,6 +287,100 @@ class IdleDaemon(unittest.TestCase): run_check(idle_daemon=pids).stdout.lower()) +class UnitsExpectedDisabled(unittest.TestCase): + """Check 2 — units nothing intends to enable on this machine. + + "Enabled" is the check's proxy for "will actually run", and the proxy is + wrong for a unit nobody means to enable here. velox carries four such + units, for four different reasons: geoclue-agent is redundant because + hyprland's exec-once starts the binary directly, emacs is started on demand + by emacsclient, obs-record-watchdog only matters while recording, and + obsbot-wb-guard needs an OBSBOT the machine doesn't have. + + Left unexempted they report at every run, which is the standing-findings + problem check 4's own comment already argues against: four permanent lines + in front of every real one teach you to skim the output. + + The exemption is machine-local rather than a marker in the shared unit + file, because obsbot-wb-guard is correctly ENABLED on ratio. Same unit, + different right answer per machine. + """ + + # --- Normal cases --------------------------------------------------- + + def test_an_exempt_unit_is_not_flagged(self): + r = run_check(unit_states="emacs.service linked", + units_expected_disabled="emacs.service") + self.assertEqual(r.returncode, 0, r.stdout) + self.assertNotIn("emacs.service", r.stdout) + + def test_a_non_exempt_unit_still_flags(self): + r = run_check(unit_states="roam-sync.timer linked", + units_expected_disabled="emacs.service") + self.assertEqual(r.returncode, 1) + self.assertIn("roam-sync.timer", r.stdout) + + def test_several_exemptions_all_apply(self): + r = run_check( + unit_states=("emacs.service linked\n" + "geoclue-agent.service linked\n" + "obsbot-wb-guard.service linked"), + units_expected_disabled=("emacs.service\n" + "geoclue-agent.service\n" + "obsbot-wb-guard.service")) + self.assertEqual(r.returncode, 0, r.stdout) + + # --- Boundary cases ------------------------------------------------- + + def test_an_exemption_that_is_actually_enabled_is_a_finding(self): + # A stale exemption must surface rather than sit there suppressing + # nothing. Otherwise the list rots into a place real findings go to + # die, which is worse than the noise it was added to remove. + r = run_check(unit_states="obsbot-wb-guard.service enabled", + units_expected_disabled="obsbot-wb-guard.service") + self.assertEqual(r.returncode, 1) + self.assertIn("obsbot-wb-guard.service", r.stdout) + + def test_comments_and_blank_lines_are_ignored(self): + # The reason a unit is exempt is the most useful thing about the + # entry, so the format has to hold a comment next to it. + r = run_check(unit_states="emacs.service linked", + units_expected_disabled=("# started on demand\n" + "\n" + "emacs.service # not by systemd\n")) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_no_exemptions_flags_everything_as_before(self): + r = run_check(unit_states="emacs.service linked", + units_expected_disabled="") + self.assertEqual(r.returncode, 1) + self.assertIn("emacs.service", r.stdout) + + def test_an_exemption_does_not_suppress_a_dangling_link(self): + # A stowed unit pointing at a missing target is a different finding, + # decided on the filesystem. Exempting the name must not hide that. + d = tempfile.mkdtemp(prefix="prc-units-") + self.addCleanup(shutil.rmtree, d, True) + unit_dir = os.path.join(d, "systemd", "user") + os.makedirs(unit_dir) + link = os.path.join(unit_dir, "emacs.service") + os.symlink(os.path.join(d, "gone.service"), link) + env = dict(os.environ) + env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "", + "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234", + "PRC_NTP_SOURCES": "162.159.200.1", + "PRC_IDLE_DAEMON": "4242", + "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "emacs.service", + "XDG_CONFIG_HOME": d}) + env.pop("PRC_UNIT_STATES", None) + r = subprocess.run(["sh", CHECK], capture_output=True, text=True, + timeout=30, env=env) + self.assertIn("points at a missing target", r.stdout) + self.assertEqual(r.returncode, 1) + + class RepoPushCapability(unittest.TestCase): """Check 8 — a working repo cloned from the read-only endpoint. @@ -665,12 +765,31 @@ class ProjectTooling(unittest.TestCase): def test_ignored_but_absent_tooling_flags(self): with tempfile.TemporaryDirectory() as root: - self.project(root, [".ai/", ".claude/", "todo.org"]) + self.project(root, [".ai/", "todo.org"]) r = run_check(project_roots=root) self.assertEqual(r.returncode, 1) - for missing in (".ai", ".claude", "todo.org"): + for missing in (".ai", "todo.org"): self.assertIn(missing, r.stdout) + def test_claude_dir_absence_never_flags(self): + # Same shape as CLAUDE.md below, and proven the same way. The bootstrap + # and the gitignore sweep write `.claude/` into the ignore set of every + # gitignore-mode project whether or not one ever exists there, so the + # entry is aspirational rather than a promise. Three projects tripped + # this on velox, and ratio is missing the identical directory in the + # identical three, which is what proves it is the steady state and not + # reinstall drift. + # + # Nor does dropping it lose a real signal. A project that genuinely + # carries one (rules and hooks from a language bundle) has it re-synced + # by sync-language-bundle.sh at every session start, so a true absence + # heals itself before this check would ever run. + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/", ".claude/"], present=(".ai/",)) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + self.assertNotIn(".claude", r.stdout) + def test_ignored_and_present_tooling_passes(self): with tempfile.TemporaryDirectory() as root: self.project(root, [".ai/", "CLAUDE.md"], @@ -797,6 +916,7 @@ class SignalAccount(unittest.TestCase): "PRC_SIGNAL_ACCOUNTS": "+15045551234", "PRC_IDLE_DAEMON": "4242", "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "", "signal_missing": "1"}) r = subprocess.run(["sh", CHECK], capture_output=True, text=True, timeout=30, env=env) @@ -867,6 +987,7 @@ class ProbeFailure(unittest.TestCase): "PRC_SIGNAL_ACCOUNTS": "+15045551234", "PRC_IDLE_DAEMON": "4242", "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "", "TMPDIR": "/nonexistent-tmp-dir"}) r = subprocess.run(["sh", CHECK], capture_output=True, text=True, timeout=30, env=env) @@ -890,6 +1011,7 @@ class RealUnitDirEnumeration(unittest.TestCase): "PRC_SIGNAL_ACCOUNTS": "+15045551234", "PRC_IDLE_DAEMON": "4242", "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "", "XDG_CONFIG_HOME": config_home}) env.pop("PRC_UNIT_STATES", None) return subprocess.run(["sh", CHECK], capture_output=True, text=True, @@ -945,6 +1067,7 @@ class WedgedSystemctl(unittest.TestCase): "PRC_SIGNAL_ACCOUNTS": "+15045551234", "PRC_IDLE_DAEMON": "4242", "PRC_REPO_REMOTES": "", + "PRC_UNITS_EXPECTED_DISABLED": "", "PRC_SYSTEMCTL": fake, "PRC_SYSTEMCTL_TIMEOUT": timeout_s, "XDG_CONFIG_HOME": d}) @@ -45,6 +45,208 @@ below): input-side-spec.org (DRAFT, four decisions open). * Archsetup Open Work +** TODO [#B] Speedtest button cancels an in-flight run :feature:dotfiles:network: +:PROPERTIES: +:CREATED: [2026-09-01 Tue] +:LAST_REVIEWED: 2026-09-01 +:END: + +From the roam inbox (routed 2026-09-01), Craig's words: "pressing the +speedtest button on network admin panel when speedtest is already running +should cancel the speedtest. However, we should leave any numbers on the +display as if the speedtest completed successfully." + +Net panel work lives in ~/.dotfiles; archsetup owns it end-to-end per the +standing rule in notes.org. Distinct from the [#C] speedtest-history task +(that one persists results over time; this one is in-flight cancel +semantics). Behavior is fully specified: second press kills the running +test, display keeps whatever numbers are already shown as a completed +result. [#B]: real improvement to the active panel family, no hard date. + +** TODO [#B] gcalcli in the installer, token carried from the other daily driver :feature:velox:tooling:solo: +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +From home's 2026-08-25 handoff: velox's 08-13 reinstall left it without +gcalcli, and on 08-21 both calendar write paths on velox were down at once +(the google-calendar MCP with expired tokens, and no gcalcli), so a booked lab +appointment sat uncalendared for three days. The daily-drivers one-time-setup +drift, exactly. + +Part 1 is done (2026-08-25, this session): =pipx install gcalcli==4.5.1= on +velox to match ratio, then ratio's =~/.local/share/gcalcli/{oauth,cache}= +copied over tailscale (=oauth= is a 1 KB pickled google-auth credential, +=chmod 600=). =gcalcli list= on velox returned all six calendars with no +re-consent, so the token is portable between the daily drivers and the OAuth +click-through is not needed when the other machine is reachable. + +Part 2, this task: make the installer do it. +- =pip_install gcalcli= in the tool set beside =pip_install yt-dlp= (archsetup + ~line 3109; =pip_install= wraps =pipx install= as =$username=). Pin or not: + ratio and velox are both 4.5.1; unpinned matches how yt-dlp is installed. +- The credential can't be installed: add a named post-install manual step + "copy =~/.local/share/gcalcli/oauth= from the other daily driver + (=scp <other>:.local/share/gcalcli/oauth ~/.local/share/gcalcli/=, + =chmod 600=), or run =gcalcli init= per + =assets/2026-02-01-gcalcli-setup.org= when neither machine has it." +- A =post-rebuild-check= item: gcalcli on PATH and the oauth file present, so + the drift is caught by the checker rather than by a missed appointment. +- Tests: an installer-steps pytest asserting the tool set carries + =pip_install gcalcli=; a post-rebuild-check test for the new item, both + states. +- When it lands, confirm back to home (=inbox-send home=) so it can retire + its "gcalcli is not installed on velox" notes. + +Grading: feature, no hard date, real improvement to the install = [#B]. +:solo: — build path (installer + checker + tests) and verify path (pytest; +the live proof already exists on velox) with no open decision. + +** TODO [#A] Topgrade guarded-upgrade spec — decisions, review, decomposition :feature:maint:dotfiles: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:SPEC_ID: 81cdfd72-db96-43d3-aa03-779878c99f3e +:END: +The waybar maint module's "topgrade freshness" warning never clears: the stamp +is written only when topgrade exits 0, and the =hypr-live-update-guard= +PreTransaction hook (mesa, wayland, hyprland, vulkan-*, nvidia-utils, +xorg-xwayland under a live Hyprland) plus any failing ecosystem step makes that +exit almost unreachable. Diagnosed 2026-08-24/25; the fix is specced, not +hacked, because it spans two repos and the design is contested. + +Spec: [[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][2026-08-25-topgrade-guarded-upgrade-spec.org]] (DRAFT). + +Four open decisions, all mine to make before the spec can move: +1. Freshness means *state* (a guarded upgrade still un-applied stays stale), + not recency (any run stamps). +2. Primary mechanism is alternative D: an armed boot-time oneshot ordered + before =getty@tty1= (no display manager to order against). +3. The boot run is arch-only (=topgrade --only system=), not the full sweep. +4. The arm flag lives on a persistent path and is one-shot. + +Then: flip the decisions DONE, run spec-review (DRAFT → READY), run +spec-response to decompose the four phases into build tasks here, file the +vNext =[#D]= kernel-reboot item, and commit the spec. + +** TODO [#A] Lock-screen clock stale after a real sleep :bug:hyprland:dotfiles:velox: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +After waking velox from a real sleep, the hyprlock clock shows a stale time +(Craig confirmed 2026-08-24: the wake-from-sleep case, not an idle-locked +screen). Three isolated tests on 2026-08-24 failed to reproduce it — hyprlock +0.9.6 repainted within a second of a display power-cycle, a three-minute +SIGSTOP, and both together with the screenshot background — so it needs a real +suspend on the real hardware. + +Grading: Minor severity (cosmetic-to-confusing, the screen still unlocks) × +most users frequently (every wake) = P3 = [#C] by the matrix; held at [#A] at +Craig's direction on 2026-08-25 so it gets run while velox is the daily driver +on the road. Revisit the letter once the manual check has an answer. + +Not :solo: — the distinguishing observation is Craig's. The check lives under +Manual testing and validation: "Lock screen after a real sleep: is the clock +frozen, or is all of hyprlock frozen?". Its three outcomes each name a +different fix: stale-then-corrects → repaint interval; frozen with live input +→ clock rendering; frozen with dead input → hyprlock hung, a crash/hang +recovery bug the =screen-lock= watchdog doesn't cover. + +** TODO [#C] post-rebuild-check: probe that Emacs frames come up Wayland-native :feature:emacs:velox:solo:quick: +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +On 2026-08-24, the first Emacs 31.1 start on velox opened its first frame on +XWayland (=:0=) with the pgtk "unsupported under X" dialog, while every later +frame went to =wayland-1=. The cause was in the Emacs config (a startup buffer +sweep killed =*Warnings*= while 31.1's warnings.el held it for a deferred +display, so the first =make-frame= failed and emacsclient fell back to +=$DISPLAY=); fixed in =.emacs.d= the same night. The trap generalizes: any +first-frame error on a PGTK daemon silently lands the session on X, and nothing +in the post-rebuild pass would notice. + +Design, under the script's fail-closed contract (a probe that cannot run +reports a finding, never a pass): +- Bound every =emacsclient= call with =timeout=, as the =systemctl= calls are. + A daemon stuck in a prompt is a finding, not a hang. +- No daemon (=emacsclient= cannot connect): a visible finding, "not checked: + no Emacs daemon, start Emacs and re-run". Emacs is started on demand here, + so this is the common state right after a rebuild, and the line is the point. +- Daemon up but no GUI frame yet: never skip, and never call + =pgtk-backend-display-class= with no frame (it errors with "Frames are not + in use"). Request one invisible frame through a waiting client in the + background, =timeout 20 emacsclient -c -F '((visibility . nil) (name . + "prc-probe"))'=, so the probe walks the same first-frame path that failed on + 2026-08-24. Then wait, bounded (poll for a frame named =prc-probe= for up to + the same 20 s), before inspecting: the =-e= must not run before the =-c= + has connected. Zero pgtk frames after the request is a finding in its own + right, because a frame request that produced nothing is the first-frame + failure this check hunts. Delete the probe frame after reading. +- Inspect every pgtk frame, not the selected display, and only pgtk frames: + a tty client frame (=emacsclient -t= in tmux) carries =$DISPLAY= as its + display parameter and its terminal is not a display, so it would both trip + the =:0= rule and make =pgtk-backend-display-class= error. + #+begin_src sh + emacsclient -e '(mapcar (lambda (f) (list (frame-parameter f (quote display)) (pgtk-backend-display-class (frame-terminal f)))) (seq-filter (lambda (f) (eq (framep f) (quote pgtk))) (frame-list)))' + #+end_src + Expected: at least one entry, every display equal to =$WAYLAND_DISPLAY=, + every class =GdkWaylandDisplay=. An empty list, a =:0= entry, or a + =GdkX11Display= is a finding. A build without =pgtk-backend-display-class= + is a finding too: the installer installs =emacs-wayland=. +- Limit, stated in the check's output: it sees live frames only. A first X + frame that was already closed is invisible, so this reports the machine's + current state, not its history. The invisible probe frame is created and + never mapped, so it exercises =make-frame= (where 2026-08-24 failed), not + the window-show path. + +Tests alongside the other checks, one per state: no daemon, no frame (probe +frame requested and waited for), probe frame never appears, Wayland-only, a +=:0= frame present, a tty client frame present alongside Wayland frames, hung +daemon, an =*ERROR*= reply from =emacsclient -e= (exit 1, a finding), non-pgtk +build. + +** TODO [#B] Timeline spine test picks the wrong "next" event off Denver :bug:dotfiles:test: +:PROPERTIES: +:CREATED: [2026-08-24 Mon] +:LAST_REVIEWED: 2026-08-24 +:END: +=make test= in dotfiles is red before any of this session's work. Two failures, +both in =settings/faces/timeline-face-spine.test.mjs=: "event bars never leave +the plot" and "exactly one event is marked as next, and it is the soonest ahead". + +NOT the bug =c96a216= fixed. Every =spineRows= call in that file is pinned to +=JUL=, and =scene()= and =EVENTS()= both default to it, so the fixture side is +already clean and the file's own guard test passes. + +TWO THINGS TO SETTLE, and they may be one bug or two: + +1. =timeline-face-spine.js:466= — =const next = timedOnly(events).find((e) => e.s + >= refMs)= takes the first array element starting at or after now, which is + the *soonest* only if =events= is sorted by start time. The test's failure + message is exactly that it is not: a bar ahead of the spine starts at x=1651.2 + while the one marked =event-next= sits at x=2132.8. Either sort before the + find, or use a min-by rather than a find. + +2. Why it is red *here* and presumably green on ratio. The most recent commit to + =timeline-face-spine.js= is =ffe43ab feat(settings): draw home where the + machine is, not where its zone is=. This machine is =America/Denver= (Craig + travelling); the tests pass =home("New Orleans")= explicitly. If a + machine-resolved home overrides the explicit argument, the geometry drifts + and the test is machine-dependent — which makes it useless as a gate, since it + would only ever fail on the machine nobody runs it on. Confirm by running the + faces suite with =TZ=America/Chicago= and again with =TZ=America/Denver=. + +If item 2 confirms, the design question is whether machine-resolved home belongs +in the pure geometry layer at all, or whether the host should resolve it and pass +it in — which is what the test already assumes. + +Not blocking the Lua port: =make test-faces= is disjoint from the hypr config and +the three suites that work touches. + ** TODO [#B] Qt apps render oversized on velox :bug:velox:solo: :PROPERTIES: :CREATED: [2026-08-19 Wed] @@ -101,26 +303,6 @@ The cursor entry in the same file records this identical failure a third time: compositor's scale." Three instances of one mistake in one file, two previously fixed in isolation without anyone naming the pattern. -** TODO [#B] Function keys issue media actions instead of F-keys :bug:velox: -:PROPERTIES: -:CREATED: [2026-08-19 Wed] -:LAST_REVIEWED: 2026-08-19 -:END: - -From the roam inbox, Craig's words: "function keys should issue F+number -functionality rather than their media functionality when the button is hit. -currently it's reversed and I have to hit function and the f button for F+number -functionality." - -Check first whether this belongs to archsetup at all. On a Framework the Fn-lock -is a firmware-level toggle held in the keyboard itself (Fn+Esc on most -revisions), not something the OS sets, in which case this is one keystroke -rather than a change here. If it is instead a hid/keyboard-module quirk, it is -ours. - -Grading: Minor severity (the keys work, they are on the wrong layer, and there -is a workaround) x every user every time (every F-key press) = P2 = [#B]. - ** TODO [#C] Waybar panels launch expanded instead of collapsed :bug:dotfiles:waybar: :PROPERTIES: :CREATED: [2026-08-19 Wed] @@ -178,97 +360,48 @@ Grading: Minor severity (nothing behaves wrong, it just waits) x some users sometimes (every failed lookup, which is occasional rather than constant) = P3 = [#C]. -** TODO [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware: -DEADLINE: <2026-08-14 Fri> -:PROPERTIES: -:CREATED: [2026-08-13 Thu] -:LAST_REVIEWED: 2026-08-13 -:END: -Machine off, lift the input cover (Framework QR-guided procedure, 5 -fasteners), reseat its ribbon connector to the mainboard — disturbed in the -2026-08-13 board swap. Root cause of every "mystery reboot" that day: -chassis flex (flash-drive touch, ethernet bump, lid partially lowered) -fired phantom power-button presses — journalctl -b -1 showed "Power key -pressed short." → orderly logind poweroff, then the glitching button -powered it back on. While in there, reseat the USB expansion cards too — -the flaky slot (two hard resets, one no-enumeration) is likely the same -flex problem. -THIRD SYMPTOM (2026-08-13 evening): touchpad delivers ZERO input events — -15s synchronized libinput debug-events capture while swiping caught -nothing, though i2c enumeration and a driver rebind handshake are clean. -Signature of a dead interrupt line on the same ribbon. Keyboard + power -LED lines work; BT mouse is the interim pointer. -ESCALATED 2026-08-13 21:00: a fourth event killed the machine THROUGH the -shield. Previous boot's journal ends mid-line (tailscaled chatter) with no -shutdown sequence at all — a hard power cut, not logind acting. So the -glitch now reaches the EC/hardware power path, which no software setting -can intercept. The reseat is the only fix, and this is a -lose-work-without-warning failure mode, not an inconvenience. -Interim shield (already live): /etc/systemd/logind.conf.d/powerkey.conf -sets HandlePowerKey=ignore — phantom presses log but do nothing; EC-level -10s hold still force-cuts. Consider keeping it even after the repair. -Verify after reseat: flex the chassis edges + partially lower the lid, then -grep the journal for new "Power key pressed" lines — zero means fixed. -Must be done before the Sunday flight — a phantom press mid-travel with the -shield on is survivable, but the connector should not be trusted at 30,000 -feet on the loose setting. - -*** 2026-08-19 Wed @ 14:40:00 -0700 Retracted: the RTC reset is not this task's, and I should not have filed it here -I attributed the 2026-08-19 network outage to this ribbon earlier today. Craig -pushed back — he reseated it before the trip to get the touchpad working — and -he is right. The evidence does not support the attribution and some of it points -the other way. - -What actually holds. Boot -3 ended at 01:33:18 with no shutdown sequence: no -power-off target, no unmounting. The next boot's kernel line reads =rtc_cmos -00:01: setting system clock to 2025-01-01T00:00:16 UTC=, a firmware default, so -the RTC was reset rather than drifted. No firmware update was applied -(=fwupdmgr get-history= is empty) and the battery is fine. - -What refutes the ribbon. This boot logged *zero* =Power key pressed= events, and -so did the four boots before it. The phantom-press symptom had genuinely stopped -after 08-15, exactly as the 08-16 session recorded. The earlier events logged a -power-key press and an orderly poweroff; this logged neither, which makes it a -different signature, not a worse version of the same one. - -What I got wrong methodologically: I anchored on the most salient open hardware -task and read association as evidence. I even wrote "I can't prove it is the -same connector" and then filed it here anyway, which is the tell. - -Two things I checked and can rule out. There were no OOM kills — the 3,433 -matching lines are a systemd unit named "Periodically re-score Claude Code -processes for the OOM-killer" firing on a timer, not memory pressure, and there -is not a single "Killed process" line. Thermal is clean; the only mentions are -boot-time zone registration at 34C and 45C. - -One real thing the same window did surface, tracked separately: a python3 crash -loop, 251 core dumps in the final ten minutes, SIGABRT with =XFreeThreads= and -=PyEval_RestoreThread= in the trace. It does not explain the RTC, because -software cannot clear it, but it is its own problem. - -The open question that would settle the RTC is for Craig, not the journal: a -long power-button hold on a Framework triggers an EC-level reset that clears the -RTC, which fits a wedged machine being forced off. A 4-second hold would not. - -*** 2026-08-17 Mon @ 19:57:42 -0700 Not done, and the two symptoms now disagree -The reseat did not happen before the flight, and velox is travelling. The -deadline blew past on 08-14. - -The two symptoms have separated, which is worth recording because it changes -what the evidence proves. The phantom presses have stopped: fifteen "Power key -pressed" entries between 08-14 04:29 and 08-15 20:04, then nothing at all -across five boots including today's. The touchpad has not — there is still no -touchpad node under =/dev/input/by-path/=, which is the same dead interrupt -line the body describes. +** TODO [#B] Signal tray icon invisible under waybar (Electron 43 well-known-name SNI) :bug:waybar:velox: +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +Since signal-desktop 8.24.0 (Electron 43.4.0) Signal's tray item registers +under a well-known bus name (=org.freedesktop.StatusNotifierItem-<pid>-1=) +and answers Properties.Get/GetAll only when addressed by that name. waybar's +GDBus proxy addresses the owning unique name instead, reads back no Id or +Category, and logs "Invalid Status Notifier Item", so the icon never shows. +With =--start-in-tray= that leaves Signal running with no window and no icon; +launching it again from fuzzel raises the existing window. Slack (older +Electron, unique-name registration) is unaffected. Measured 2026-08-25 with a +bus monitor: the same connection returns the value for the well-known name +and "error occurred in Get" for its own unique name. + +Grading: Major severity (the app is unreachable from the desktop while +"running") × every user every time on velox = P1 by the matrix, held at +[#B] because the workaround (relaunch to raise the window) is cheap and the +fix is upstream. + +Upstream: [[https://github.com/Alexays/Waybar/issues/5240][Waybar #5240]] (open, proposes a raw-call fallback in item.cpp +proxyReady) and [[https://github.com/signalapp/Signal-Desktop/issues/7992][Signal-Desktop #7992]] (open, "Upstream Change Needed"). +Downgrading to 8.23.0 is closed off: 8.24.x migrated the SQLCipher schema to +1770 and 8.23.0 quits with DBVersionFromFutureError (tried and reverted +2026-08-25). + +Re-test after a waybar or signal-desktop upgrade, from the repo root: +#+begin_src sh :results output +grep -c 'Invalid Status Notifier Item' "$(\ls -t ~/.local/var/log/waybar-*.log | head -1)" +n=$(busctl --user list --no-legend | awk '$1 ~ /StatusNotifierItem-/ && $3=="signal-desktop"{print $1}') +busctl --user call "$n" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id +busctl --user call "$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$n" | cut -d'"' -f2)" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id +#+end_src +Expected when fixed: 0 "Invalid" lines in a fresh waybar log, or both Get +calls returning the Id (either side fixing it clears the icon). -So the quiet power button is not evidence the connector reseated itself. The -interrupt line is the symptom that cannot be masked in software, and it is -still dead, so the ribbon is still unseated. The most likely reason the -presses stopped is that the machine has been sitting on hotel surfaces instead -of being carried and flexed. +Ratio is on 8.21.0 and unaffected until its next upgrade brings 8.24.x. +Alternatives if it drags on: change Signal's tray setting so it keeps a +window (=~/.config/Signal/ephemeral.json= =system-tray-setting=), or run a +waybar carrying the #5240 fallback. -The interim shield is still live (=HandlePowerKey=ignore=), and the escalation -note stands: an EC-level glitch cuts power below systemd regardless of it. ** DOING [#A] Velox reinstall — DR test of archangel + archsetup :velox:chore: DEADLINE: <2026-08-15 Sat> :PROPERTIES: @@ -352,62 +485,6 @@ failures, so a regression here is uniquely undetectable. P2 = [#B]. :solo: — the surface is one script and its suite, the refactor is behaviour-preserving, and the existing 58 tests plus a mutation battery are the objective check that it stayed so. -** TODO [#A] powerprofilesctl crashes on a loop since ppd was masked :bug:velox:dotfiles: -:PROPERTIES: -:CREATED: [2026-08-17 Mon] -:LAST_REVIEWED: 2026-08-17 -:END: -Something polls power state every 10-30 seconds, and each poll runs -=powerprofilesctl get=, which SIGABRTs. 47 coredumps on velox on 2026-08-17 -alone, the earliest at 08:34, four in one minute while I was watching. - -Cause is the 2026-08-16 fix that masked =power-profiles-daemon= so TLP -survives on laptops. That fix is right and stays. What it did not account for -is the settings module's power backing -(=~/.dotfiles/settings/src/settings/power.py=), which shells out to -=powerprofilesctl=. Against a masked unit the D-Bus activation fails with -=NameHasNoOwner ... unit is masked=, and the caller aborts rather than -degrading. - -Run by hand the same command exits 0 and prints the error, so the abort is -context-dependent and the caller needs finding before the fix is written. -Ratio does not mask ppd, which is why this is velox-only and why it appeared -the day after the masking. - -Costs: journal spam, coredump disk churn, and repeated failed D-Bus -activations on a travelling laptop's battery. It is also the leading suspect -for the wedged user manager filed below. - -Fix shape: =power.py= should treat a masked or unavailable ppd as a -first-class "no profile control here" state rather than an error path, and -the poller should stop retrying a unit it has been told is masked. The -machine-level half is already correct. - -Grading: Major severity (a crash loop burning battery and filling the -journal, silently) x every user every time on any laptop with the TLP fix -applied = P1 = [#A]. - -*** 2026-08-17 Mon @ 19:57:42 -0700 The loop stopped at the reboot; the defect did not -velox rebooted at 16:04 and there have been zero coredumps since, against 47 -in the twelve hours before it. So the loop is not currently burning anything. - -That is not a fix, and the distinction matters for whoever picks this up. -=powerprofilesctl get= still fails exactly as recorded — =NameHasNoOwner ... -unit is masked= — so every precondition for the loop is intact and it returns -whenever the caller next polls. What the reboot cleared is the caller's state, -not the bug. - -Narrowed the search the body asks for: =power.py= is the *only* file in -dotfiles that shells out to =powerprofilesctl= (=SETTINGS_POWERPROFILESCTL=, -line 14), so the caller is inside the settings module rather than waybar or a -timer. Worth knowing that the coredumps are =powerprofilesctl= itself aborting -— it is a python script, which is why they log as =/usr/bin/python3.14= -SIGABRT rather than under its own name. - -Grade unchanged. The matrix inputs did not move: the severity is what happens -while the machine is in that state, and the frequency row is every laptop -carrying the TLP fix. A quiet interval since a reboot is not a frequency -change. ** TODO [#B] velox's systemd --user spins at 96% and cannot resolve unit files :bug:velox: :PROPERTIES: :CREATED: [2026-08-17 Mon] @@ -466,63 +543,6 @@ instruction rather than by a fresh judgment. Reproducing it deliberately is the open question, and it is not obviously worth doing — it costs a wedged session to learn something the crash-loop fix may make moot. -** TODO [#A] The installer clones my two working repos shallow and read-only :bug:velox: -:PROPERTIES: -:CREATED: [2026-08-17 Mon] -:LAST_REVIEWED: 2026-08-17 -:END: -=archsetup:1432= clones the user's archsetup repo and =archsetup:1445= clones -dotfiles, both with =--depth 1=. Those are not build directories. They are the -two repos I actively develop in, and on velox they came back from the -2026-08-13 rebuild with 7 commits of history each instead of 851. - -Found 2026-08-17, and found the worst way: I ran the credential-file history -check that the GitHub-release task asks for, and it reported all five files -absent from history with a clean exit. The real answer is that this clone -cannot see the history those files live in. A shallow clone does not error on -=git log -- <path>=, it answers "no commits" — so a security question came back -falsely clean, and nothing about the output said otherwise. - -Everything else it breaks is quieter: =git log=, =blame=, =bisect=, and any -archaeology past the boundary. The tree looks completely normal, which is why -this survived four days on the machine. - -The right shape is already in the codebase. =scripts/post-install.sh:42-51= -takes depth as a per-repo argument and defaults to a full clone, so wallpaper -gets =--depth 1= and org does not. The AUR build clones (=archsetup:855=, -=:1673=, =:1677=) are correctly shallow and stay that way. Only the two -user-repo sites change. - -*Second defect, same two lines, found 2026-08-17 while pushing:* the dotfiles -clone could not push at all. =archsetup:245= defaults =dotfiles_repo= to -=https://git.cjennings.net/dotfiles.git=, the public read-only endpoint, so -=git push= returned 403. Ratio uses =git@cjennings.net:dotfiles.git= and -archsetup's own clone uses the matching ssh form, so velox was the odd one out -purely because it was the machine rebuilt by the installer. Repointed velox's -remote and pushed. - -That half needs a decision rather than a fix, which is why this task is no -longer =:solo:=. The https default is *correct for a stranger* installing -archsetup, who has no ssh key on the server, and this repo is being prepared -for public release. It is wrong for my own machines, which need to push. The -override already exists (=DOTFILES_REPO=, documented in -=archsetup.conf.example=), so the question is only where my personal value -lives: a config the personal ISO bakes in, a post-install step, or a detection -that prefers ssh when a key is present. Craig's call. - -Repair on a machine already built: =git fetch --unshallow= in each repo, and -=git remote set-url origin git@cjennings.net:<repo>.git= for dotfiles. - -Grading: Major severity (two working repos silently missing their history on -the machine I develop on, and it returns confidently wrong answers to history -questions rather than failing) x every user every time (every fresh install, -both daily drivers) = P1 = [#A]. - -Not :solo:. The depth half is (two lines plus tests in the existing -=tests/installer-steps/= shape, verifiable by asserting the clone command -carries no =--depth= for these two repos). The remote-URL half needs the -decision above, so the task as a whole waits on it. Split it in two if the -depth fix is wanted sooner. ** TODO [#B] post-rebuild-check needs a reference-host mode :feature:velox:solo: :PROPERTIES: :CREATED: [2026-08-17 Mon] @@ -556,6 +576,27 @@ close. Most-users-frequently x Major = P2 = [#B]. :solo: — the checks exist, the ssh path is proven (the 2026-08-17 session ran exactly this comparison by hand), and correctness is verifiable locally by diffing the two reports. +*** 2026-08-21 Fri @ 07:10:00 -0700 The premise moved: velox now reports 1 finding, not 8 +Re-scope before building. The 1:7 ratio this task argues from is gone, and two +of the three things it cites as noise are fixed at the source rather than +filtered. + +=87ff0b7= gave check 2 a machine-local expected-disabled list, so the four unit +findings are declared intent rather than noise, and an entry whose unit turns +out to be enabled is itself reported so the list cannot rot. =3fbf3e0= dropped +=.claude= from check 4's expected set, since the gitignore sweep writes that +line into every project whether or not one exists. velox went 8 findings to 1. + +So the open question is no longer "how do we cut the noise" but whether a live +reference-host diff still earns its place against a static declaration of +intent. They are different tools: the list is offline, explicit, and states +what a machine means; the diff is automatic and catches drift nobody declared. +The reference-host comparison is still what *found* all of this, twice, by +hand. That is an argument for it and not against. + +Worth knowing this task already contained the whole 8-to-1 analysis when it was +filed 2026-08-17, and a session on 2026-08-20 re-derived it from scratch without +reading it. Not :solo: any more — the design call above is Craig's. ** TODO [#C] screen-lock test suite red on ratio :bug:test:dotfiles: :PROPERTIES: :CREATED: [2026-08-13 Thu] @@ -612,6 +653,33 @@ and turns a silent no-op into a visible line: 5. =signal-cli listAccounts= non-empty. velox lost its registration, and because agent-text relays to a hardcoded velox, that breaks the phone channel for the WHOLE FLEET, not just this machine. +6. =mbsync --list= parses. The Proton Bridge TLS cert + (=~/.config/protonbridge.pem=, referenced by =~/.mbsyncrc=) is generated + per *installation*, so it cannot be restored or copied between machines. + Its absence aborts the config parse, which kills *every* account — gmail + and dmail need no bridge and died anyway. The error names only the missing + pem, so "no mail at all" and "this one file is missing" look unrelated. + Re-derive it off the running bridge's own handshake, no GUI, no secrets: + =openssl s_client -connect 127.0.0.1:1143 -starttls imap -showcerts </dev/null | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > ~/.config/protonbridge.pem= +7. The bridge password (=~/.config/.cmailpass=) is per-install too. It is a + real file rather than a stow symlink, so it survived the rebuild holding + the *previous* install's value — worse than absent, because it looks + right. Diagnostic trap: the bridge answers a wrong password with =no such + user=, which reads as "no account signed in" and sends you hunting a login + problem that doesn't exist. Never treat =no such user= as evidence about + account state. + +*The distinction that organizes all seven* (from the .emacs.d handoff +2026-08-14, inbox): every artifact that broke was generated on the machine by +an application rather than carried by git, stow, or dotfiles. But they split +two ways, and conflating them is what produces a file that exists, looks +right, and authenticates against nothing: +- *Restore* — the old value is still correct: gitignored tooling (1), + roam clone state (3), =*.local.el= configs (5). +- *Re-derive* — the old value is worthless because the application minted a + new one: signal-cli registration (4), bridge cert (6), bridge password (7). +So the checklist wants two columns, not one. + Graded [#A] because item 5 is live right now and silently disables paging, and because the flight is Sunday. ** TODO [#B] Restoring a git repo from backup can resurrect a dangerous diff :bug: @@ -758,6 +826,26 @@ archangel+archsetup ISO that's already ~80% built. Two ISO modes: generic don't start the migration until the credentials are rotated. Not started. Not :solo: — repo standup and history rewrite are Craig's calls; promote to a real spec (spec-create) when work resumes. + +*Also bake the push-capable repo URLs into the personal ISO* (decided +2026-08-19). =archsetup:240= and =:245= default =archsetup_repo= and +=dotfiles_repo= to =https://git.cjennings.net/...=, the anonymous read-only +endpoint. That default is right for a stranger installing archsetup — no key on +the server — and wrong for my machines, which have to push: velox came back +from its rebuild unable to push either repo, and I only found out at a 403 four +days later. I decided against detecting an ssh key in the installer, because +archsetup never restores =~/.ssh= (I do that by hand), so key-presence at clone +time depends on ordering the installer doesn't control, and a naive "any key +means ssh" would break a stranger who happens to have one. The override already +exists and is documented — =ARCHSETUP_REPO= / =DOTFILES_REPO= in +=archsetup.conf.example= — so the personal ISO just needs to carry the ssh +form of both, alongside the secrets bundle. The generic ISO keeps the https +default untouched. + +The gap that leaves is a curl|bash or stock-ISO install, which takes the https +default straight back. =post-rebuild-check= check 8 covers that path — it flags +a working repo whose origin is the read-only endpoint — so the ISO value is the +fix and the check is the net under it. ** TODO [#B] Settings toggles reset silently at session start :bug:dotfiles: :PROPERTIES: :CREATED: [2026-07-28 Tue] @@ -999,29 +1087,6 @@ Not =:quick:= despite being small: four pieces with tests is a sitting rather th From the roam inbox (Craig, claimed 2026-07-23): the wallpaper channel switches on sunrise/sunset today (the sun-pair mode, =settings/src/settings/wallpaper.py=, location read live via whereami with a state.json cache). Add a timed-schedule mode as an alternative: fixed clock times drive the transitions rather than the solar calc. Not :solo: — the capture itself flags the missing inputs ("we'll need to know the transition times, and how many of them there are"). The count and the times are a design decision Craig owes: is it a two-image day/night flip at fixed hours, an N-way ring across the day, per-image dwell vs shared interval? The =set= channel already does fixed-interval cycling through a set, so the new part is specifically clock-anchored transition points, not just "a timer". Ask for the schedule shape at pickup, then build against the existing wallpaper.apply presenter vocabulary. -** TODO [#D] Worldclock tooltip blanks on one bad timezone row :bug:dotfiles:waybar:quick:solo: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-25 -:END: -Found by sentry (2026-07-25), verified by exercising. =hyprland/.local/bin/waybar-worldclock= builds each zone with =ZoneInfo(tz)= inside the loop (line ~99) with no guard, so a single malformed timezone row in =worldclock.conf= raises =ZoneInfoNotFoundError= and crashes the whole python pass. The tooltip then renders empty and *every* zone is lost, not just the bad row; the traceback only reaches stderr, where waybar never surfaces it. -Repro: a conf with =America/Chicago|Home=, =Not/AZone|Bad=, =Europe/London|London= renders =tooltip: ""= (Home and London gone too). -Grade: minor severity (one module's tooltip blanks, no data loss) x rare edge case (a malformed conf row) = P4 = [#D]. -Fix: wrap the per-row =ZoneInfo=/=datetime= in a try/except and =continue=, so a typo drops only that row and the valid zones still render. Solo + quick: the script already has an env-override test harness (=WAYBAR_TIME_EPOCH=, =WAYBAR_WORLDCLOCK_CONF=), so a red-first test is cheap. -** TODO [#C] obsbot-wb-guard polls forever on machines with no OBSBOT :bug:dotfiles:quick:solo: -:PROPERTIES: -:LAST_REVIEWED: 2026-08-16 -:END: -=obsbot-wb-guard.service= is =WantedBy=graphical-session.target= and lives in the shared =common/= stow tier, so it starts on every machine. Its main path is =while :; do check_once; sleep 2; done=, and =check_once= returns early when the camera node is absent. On a machine with no OBSBOT attached that is a process waking every two seconds forever to do nothing, which on a laptop is battery spend for zero benefit. No restart loop, though: the loop never exits, so =Restart=on-failure= never fires. - -Found 2026-08-16 on velox, after enabling it to match ratio and then having to disable it again by hand. A per-machine disable is the wrong shape, because it drifts velox from ratio permanently and a re-stow or a future audit will just put it back. - -Fix: give the unit =ConditionPathExists= on the camera node (=/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_PW106-video-index0=, the same default the script uses) so systemd skips it on any machine without the camera and starts it normally on ratio. Then re-enable it on velox, where it will simply be skipped. Note the limit: a camera plugged in later will not start it until the next login, which is the right trade against a permanent poll. - -Careful when disabling by hand in the meantime: =systemctl --user disable= on a *linked* unit deletes the unit symlink, and that symlink is stow-managed, so a bare disable silently removes a file from the dotfiles stow tree. Restore the link afterward or re-stow. - -Grade: minor severity (wasted wakeups and battery, no data loss, no failure) x every boot on any machine without the camera = P3 = [#C]. - -Solo: buildable here (archsetup owns dotfiles end-to-end), verifiable by the agent (assert the unit is skipped on velox and still active on ratio), and no design call left open. ** TODO [#C] Auto-dim status forgotten on layout change :bug:dotfiles: :PROPERTIES: :LAST_REVIEWED: 2026-07-25 @@ -1042,20 +1107,6 @@ From the roam inbox (Craig, claimed 2026-07-24): the network, bt, maint, and aud :LAST_REVIEWED: 2026-08-02 :END: From the roam inbox (Craig, claimed 2026-07-24): panel labels look cut off; a few more pixels of space fixes it. He named the audio and bt panels, but his "before" capture is the networking panel (=~/pictures/screenshots/2026-07-23_202419.png=; "after" resizing =~/pictures/screenshots/2026-07-23_202458.png=), so the whole panel family likely shares the tight spacing. Confirm which panels clip at pickup, then add the padding/width. Grade: cosmetic × every glance at the affected panels = P3 = [#C]. Solo — buildable (CSS/size tweak) and screenshot-verifiable, no design call once the clipping panels are identified. -** TODO [#C] Spine face tests decay against the wall clock :bug:test:dotfiles:solo: -:PROPERTIES: -:LAST_REVIEWED: 2026-08-02 -:END: -=settings/faces/timeline-face-spine.test.mjs= has thirteen =SP.spineRows(g, h)= calls that omit the third argument, so =ref= falls back to its =new Date()= default while the file's events fixture is pinned to =JUL= (2026-07-31 18:30 UTC). Any assertion that depends on how much room the day needs is then measured against today's clock, and rots as the fixture recedes. - -One of them, "spacing is uniform everywhere except the gap home opens", had already rotted: green on 07-31 because that was the fixture's own date, red by 08-02. Fixed in place on 2026-08-02 by pinning =JUL=; the remaining thirteen pass today by luck. The measurement, for whoever picks this up — with =ref=now= the even step is 85.21 and home's gaps are 129.10 / 65.40 (the lower one collapses below a plain gap); with =ref=JUL= the step is 78.54 and the gaps are 129.10 / 145.46. Only the lower gap moves, because =up= does not depend on events and =down= does. - -Six other calls in the same file already pass =JUL= explicitly, so the convention exists and this is a miss, not a gap in the design. Fix: pass =JUL= at every call whose assertion reads geometry. Leave the call around line 747 alone — it sweeps =new Date(t0)= deliberately. - -Grade: minor severity (dev-facing only; no product behavior is wrong, the face itself is fine) x some users, sometimes (each call rots independently, whenever the fixture drifts far enough) = P3 = [#C]. Not merely cosmetic though: a suite that goes red for no real reason is how a genuine regression gets waved through. - -Solo — mechanical, an existing convention to copy, and verifiable by running the suite plus re-running it under a faked clock to prove the determinism actually holds. - ** TODO [#C] Night-watch live telemetry :feature:maint: :PROPERTIES: :LAST_REVIEWED: 2026-08-02 @@ -1124,19 +1175,49 @@ snapshot. =origin= on this machine is still =git@cjennings.net:archsetup.git=, the cgit account, so nothing has moved. Everything in the plan stands unchanged. + +*** 2026-08-21 Fri @ 14:12:46 -0700 Recorded the publication mechanism: placement is the only control +The work project verified its own repo reads "not served" against a control +repo that reads PUBLIC, and reported the mechanism back: the host publishes via +=GIT_HTTP_EXPORT_ALL= over =GIT_PROJECT_ROOT=/var/git=, so *publication is +directory placement and nothing else* — there is no per-repo marker, no +=git-daemon-export-ok= file, no opt-in flag to check. A repo is public because +of where it sits. + +That is the durable hazard for this task's plan, and it cuts both ways. It +confirms the approach — a bare repo created outside the scan-path is private by +construction, which is exactly what the plan already specifies. It also means +nothing in a repo itself records whether it is exposed, so any future move +*into* =/var/git= publishes silently, with no local artifact to notice. Their +own repo is private for this reason alone: it lives under =/var/cjennings/git/=, +outside the served root. + +Caveat they raised and I agree with: any enumeration of the served set is a +snapshot, not a standing fact. The set moved twice while three projects were +measuring it. Verify placement at the time of the move rather than trusting a +recorded list. ** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-26 :END: Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/maint code review: - maint's UPDATE remedy runs a plain =yay -Syu --noconfirm= (remedies.py:297). The live-update guard (guard.py) only matches mesa/hyprland (the 2026-06-07 live-swap class) — it never checks /boot, kernel, initramfs, or mkinitcpio exit. No post-upgrade /boot assertion exists. An interrupted kernel transaction slips straight through. - Add a post-upgrade /boot assertion: after a transaction touching linux/linux-*, confirm vmlinuz-* + initramfs-*.img present and mkinitcpio exit 0; refuse to end the run (or page Craig) otherwise. Would have caught this. - Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile. - CONFIRMED (2026-07-21): the pre-pacman snapshot hook fired on velox — the 2026-07-15 no-kernel boot was recovered via the pre-pacman ZFS snapshot rollback, and velox is back on the tailnet running linux-lts 6.18.38 with initramfs present (2026-07-19 session). Root-cause hook-ordering fix shipped separately. Still open: the post-upgrade /boot assertion in guard.py and the sanoid-vs-actual dataset drift reconcile (the two bullets above). +*** 2026-08-26 Wed @ 22:35:01 -0600 The /boot assertion now lives in the topgrade spec; the dataset drift is what remains here +The post-upgrade /boot assertion is covered by the kernel-modules-check gate in +[[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][the topgrade guarded-upgrade spec]] +(dkms built for the new kernel, initramfs newer than vmlinuz, pre-pacman +snapshot on a ZFS root), which ships with that spec's Phase 1 rather than here. +What this task still owns is the sanoid-vs-actual dataset drift: whether to +split zroot/var/log and zroot/var/lib/pacman out as configure_zfs_snapshots +assumes, or change the config to match the layout velox actually has. That is +a call I have not made, so the task stays [#B] and not solo. -** TODO [#B] Assess a Hyprland left-drag window gesture :feature:hyprland: +** TODO [#C] Assess a Hyprland left-drag window gesture :feature:hyprland: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-26 :END: Evaluate whether a global left-click drag can move ordinary windows without breaking application selection, text interaction, or Wayland security @@ -1145,35 +1226,94 @@ any binding. ** TODO [#B] Reconcile panel keybindings around Super+N :feature:hyprland: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-21 :END: -Swap the notification and networking bindings so primary panels are one -Super-plus-letter chord away, audit the other exceptions, and bring the -proposed family to Craig for a final mapping decision. +Put every panel on one consistent chord family — net, bluetooth, audio, timer, +and the maintenance console — as a shared modifier set plus a mnemonic letter +per panel (N/B/A/T/M). Today they open by waybar click only, so a uniform +family is what makes them keyboard-reachable and predictable. The immediate +move is swapping the notification and networking bindings so the primary panels +sit one Super-plus-letter chord away. + +Maintenance (M) is the chord I want first — it is the panel I keep reaching for +without one. + +Constraints: +- Super+Shift+A is already the PTT toggle, and the hold-to-talk grave bind is + load-bearing. Audit every current hyprland bind for conflicts before + proposing a family, and treat these two as fixed. +- Both machines have to work the same way. Velox can't QMK-remap, so the chords + have to be typable on a plain laptop keyboard. + +Steps: settle the modifier family, audit the existing binds for collisions, +wire it through the dotfiles hyprland config, and document it in the keybind +reference. + +The family itself is the one call I haven't made — the audit and the wiring +follow from it, so that decision comes first rather than last. + +*** 2026-08-21 Fri @ 14:15:22 -0700 Merged the duplicate keybinding-family task into this one +Two tasks were carrying one job: this one and =[#B] Consistent keybinding family +for the panel console=, filed separately and both stalled. This one had the +tighter framing and the more recent review; that one had the better body — the +specific collisions, the velox plain-keyboard constraint, and maintenance-M as +the priority chord. Folded its detail in here and cancelled it, since two +half-specified tasks for one decision is plausibly why neither moved. + +Not =:solo:= and not =:quick:=: the modifier family is a preference call I have +to make, judging what's load-bearing among the existing binds needs me too, and +the audit plus wiring plus docs runs past thirty minutes on its own. ** TODO [#B] Add storage-capacity signals to the maintenance module :feature:maint: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-26 :END: Investigate capacity and growth diagnostics for full disks, identify the appropriate remedies, and incorporate a clear storage signal into the maintenance console. -** TODO [#B] Add per-channel controls to the audio panel :feature:audio: +** TODO [#C] Add per-channel controls to the audio panel :feature:audio: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-26 :END: Expose channel-level input and output volume controls without losing the existing device-level workflow. ** DOING [#B] Widget gallery upgrades :feature:design: :PROPERTIES: -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-23 :END: -Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge. +Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work. Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm. + +The =gallery-upgrades= branch this originally described is gone — no local or remote ref, and every gallery commit since has landed straight on main. Whether it was squash-merged or abandoned, the branch stopped describing how this work runs, so the line came out at the 2026-08-23 review rather than being left to mislead. Work on main. *** TODO Extraction-readiness bar for every gallery component :refactor:design: Craig's standing directive (2026-07-18, set while finishing the split-flap): every =DUPRE.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =DUPRE_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice. +*Audited 2026-08-23 — the sweep covered the bulk and stopped short.* Ten commits +on 2026-07-18 (=43725ff= … =1dd929d=) carried ~104 of the 112 builders over. Two +criteria are fully met: 105 builders carry a real contract comment naming opts +and the handle surface, and component CSS is wholly consolidated — the gallery's +own =<style>= block holds nothing but page chrome (masthead, grid, toc, card +frames, validation lamps, palette). Three gaps remain, and they are the whole of +what's left: + +1. *Seven builders were never swept*: =telegraphIndicator=, =radarSweep=, + =dotMatrix=, =flipDisc=, =dekatron=, =gearIndicator=, =blinkenlights=. Each + carries a one-line comment describing what the widget does, with no opts, no + handle surface, no CSS statement. They sit at the tail of =widgets.js= after + =responseGraph=, and the last batch was "well-through-response" — the sweep + stopped one builder short of the end and never came back. Not a clean cut: + =dayDateCal= is in that tail and does have a contract. +2. *The bar was never written down.* The README documents the API shape (Builder + contract) and =DUPRE_CSS= (Styling) — two of the five criteria. The checklist + itself appears nowhere, so a new builder inherits nothing and the sweep has to + be re-derived from this task every time. +3. *One page-global reach survived*: =patchBay= does + =window.addEventListener('resize', draw)= and never removes it. Fails the + no-page-globals criterion and leaks besides — a torn-down instance keeps + redrawing on every resize. The other two =document= reaches are benign + (=indexPlate= guards a shared SVG def; the other is the CSS injector). + *** 2026-07-18 Sat @ 04:32:20 -0500 Made the N20 split-flap an honest Solari mechanism =GW.splitFlap= rebuilt from the drop-fade fake: charset-as-drum stepping (=opts.chars= is the flap order, one flip at a time through intermediates, staggered arrival), re-aim-not-queue retargeting, and the real two-half-panel fold (WAAPI, backfaces hidden), with =animate:false= collapsing to instant jump for reduced motion. Handle grew =setText=/=chars=/=reading()=; default width 3 → 4 cells. Nine probe checks written red-first (arrival, intermediates, one-flap stepping, re-aim discriminator, instant path); technique studied from HotFX and re-derived — no license on their repo, nothing copied (reference filed in =working/retro-stereo-widgets/references/=). Review: sound; its two test-strength notes addressed in the same change. @@ -1330,11 +1470,25 @@ The cross's cell assignments were read off card names and spec sheets, not audit ** DOING [#B] Retro widget catalogue :feature:design: :PROPERTIES: :SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-23 :END: The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56, then reopened at R57 as the taxonomy found empty cells (110 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]). Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive. + +*The eight open subtasks are really one decision plus three builds* (noted at the +2026-08-23 review, because "8 open" reads as more contested than it is). Phase 1 +shipped. Phases 3, 4 and 5 and the spec flip are each gated, directly or +transitively, on *Phase 2 — the demand inventory*, which is my matrix to write +and nobody else's. That single artifact has been the whole chain's blocker since +2026-07-12. The three genuinely independent items are the magic-eye rebuild, the +wind-direction rose, and weather kit integration. + +Weather kit integration may already be unblocked: its note says live panel +verification "awaits a stowed desktop with a private weather location +configured", and both daily drivers are stowed now. Check whether +=$WEATHER_LAT=/=$WEATHER_LON= or =~/.config/weather/config.json= is set before +treating it as still waiting. *** TODO [#B] Rebuild the magic-eye tube component :feature:design: Reinstate the magic-eye tuning/level indicator (EM34/EM84/6E5 family), but replace the earlier weak UI rather than reviving it unchanged. The component @@ -1396,10 +1550,10 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec. *** TODO Flip the spec to IMPLEMENTED When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file. -** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network: +** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:solo: :PROPERTIES: :SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-25 :END: Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below. *** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe @@ -1419,23 +1573,15 @@ On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on *** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs. -** TODO [#B] Consistent keybinding family for the panel console :feature:hyprland: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-09 -:END: -Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard). -*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B] -Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog. - ** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles: :PROPERTIES: -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-25 :END: The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes." The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free. -The shared helper is built (see the dated entry below). What remains is per-panel adoption: wire each doctor's remedies through =panelkit.privmodel.resolve()= and audit them against the Confirm/Arm floor, and reconcile maint's =priv.py= build/fire table with the model rather than leaving its implicit always-passwordless assumption. That wiring lives in the per-panel fix phases (net Phase 1, bt Phase 2, audio input/output), each needing a real privileged host to verify =--fix= end to end, so none is agent-solo. +The shared helper is built (see the dated entries below), maint is reconciled onto it, and net is wired: =classify.py= carries the =remedy_class= on its privileged verdicts and =doctor.py= resolves each through =panelkit.privmodel.resolve()= (net Phase 1, shipped 07-11). Adoption is the gate only — every panel's repair actions already exist; what adoption changes is whether and how an existing privileged action is allowed to run (RUN where passwordless, PROMPT on a CLI tty, GUIDE in a GUI), under the Confirm/Arm floor. What remains, checked against the tree 2026-08-25: bluetooth is part-wired (=bt/doctor.py= makes one =resolve(PRIVILEGED, ...)= call, no per-remedy classes yet — audit its individual fixes against the floor), and audio has nothing on the doctor side (pending the input-side spec). Each needs a real privileged host to verify =--fix= end to end, so not agent-solo. Craig's decision, 2026-07-12: maint's harmless-reclaim privileged remedies (the silent CLEAN UP set — paccache keep3, journal vacuum, coredump clean) STAY silent-auto. The reconciliation gives that class a sanctioned, documented exception to the confirm floor rather than forcing Confirm/Arm; the value of the floor holds for everything else. Where sudo is not passwordless, maint should degrade per the model (prompt on a tty, guide in a GUI) instead of hard-failing. @@ -1460,7 +1606,7 @@ The support machinery was deliberately kept for this task: =layout-navigate= and ** TODO [#B] Audit dotfiles/common directory :chore:dotfiles: :PROPERTIES: -:LAST_REVIEWED: 2026-07-14 +:LAST_REVIEWED: 2026-08-25 :END: Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts: - Review all 50+ scripts in =~/.local/bin= and remove unused ones. @@ -1472,134 +1618,32 @@ ACTION before the kill pass: redo the reference scan to grep all invocation sour *** 2026-07-14 Tue @ 01:40:48 -0500 Built the audit evidence report Shipped as =docs/2026-07-14-bin-audit-evidence.org= in the dotfiles repo (260752a). 150 scripts bucketed: 69 keep (referenced or cron-driven), 74 kill candidates (zero references in the tree), 7 flagged (all dwm-tier, expected on a hyprland host). Config sweep: audacious and wofi configs are orphan candidates, ranger needs an install-vs-delete call (declared in archsetup but not installed on ratio). Shell history was too shallow (~700 lines) to prove by-hand disuse either way — the kill pass stays Craig's call in the parent task. -** TODO [#B] Waybar network module — custom/net :feature:waybar:network: +** TODO [#C] net vpn CLI subcommand :feature:network:dotfiles:solo: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-08-21 :END: -Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager -dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine -(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code -lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook); -archsetup only installs deps. Secrets stay in NetworkManager's own store (no -separate credential store). The =captive= script becomes the diagnostics engine. -Full design, acceptance criteria, and the failure-mode coverage table: -[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]]. - -Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest= -+ fakes on PATH, coverage via venv); the live-network and visual states need real -conditions, filed under "Manual testing and validation". - -*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery -Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main). -The =net= engine is a src-layout Python package in-tree, imported by a bin shim -that resolves the stow symlink back to the repo — so it runs from a bare TTY with -no install, which the recovery path depends on. +=cli.py= in the dotfiles =net/= package has no vpn/tunnel parser, so everything +the panel can already do with tunnels has no command-line equivalent. Fold the +panel's existing tunnel operations into a =net vpn ...= surface mirroring what +the Tunnels sub-view does — bring an overlay up, take it down, report status. -Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in -budget) + =net probe= (native captive probe, single-flight flock, atomic cache, -fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net= -replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes + -live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/ -dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal -classifications; =net portal= + the =captive --probe-json= refactor; redacted -JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/ -config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi. +The operations themselves already exist and are tested: dotfiles =2d9d060= +probes tailscale / NM-wireguard / Proton, =21db05a= brings overlays up and down +from the panel, =31ba056= taught diagnose and doctor to understand tunnel +routes, and archsetup =2e40781= imports wireguard configs. This is a CLI surface +over shipped behavior, not new capability. -Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net -reads the airplane-mode state file and shows an airplane state/glyph. The -airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness + -services — not a radio switch), now on =custom/net='s right-click + signal 15. -Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests + -css. =airplane-mode= kept. - -Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/ -systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow- -nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32 -suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch -(classify 100%), IO-error branches excused in the test docstring. -Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2, -speedtest-go-bin Phase 3 — not added before the code that needs them). -Verify (manual, live): see Manual testing and validation. +Graded [#C] rather than [#B]: the panel already does the job, so this is +convenience rather than a gap. It earns a bump if I find myself wanting tunnel +control from a bare TTY — which is the same recovery-path argument that made the +rest of =net= worth having as a CLI. -*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management -Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved -MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed, -mutation safety — keep prior link until target activates, classify wrong-password vs -generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK; -enterprise activate-only; secret to NM's store, never our JSON/log — tested). +=:solo:= — the subcommand shape is obvious (it mirrors the panel), the =net= +package's fake-based harness covers the build and verify path, and archsetup owns +the dotfiles work end to end. Not =:quick:=: every prior net phase landed with +twenty-odd new tests and a review pass, so this runs past a spare moment. -Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable -rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored -top-right under the bar — Connections section with MRU list, active marked, signal -glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread -engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free. - -Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle, -middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open -a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on -Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit); -already on velox. - -Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak -on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite -green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24 -profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/ -speedtest IN the panel) is next; the engine for it already exists from Phase 1. - -*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel -Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin), -pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns, -down/up from per-server byte rates; missing-backend / offline / malformed → error -envelope per the failure table). Panel grew a section switcher with four pages: -- Connections (Phase 2). -- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title + - redacted evidence), read-only; Open-portal button when captive. -- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test) - + force portal. Confirmations in-panel with the spec's exact wording; the privileged - tiers run via =net-popup= terminal (where the sudo prompt + step output, incl. - cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op. -- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping - + server), Run/Cancel (Cancel pkills the child), error envelope shown. - -213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full -dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit -(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The -in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege -story settles. - -The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4 -(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core -feature (indicator + recovery + panel + diagnostics + speed test) is done. -Verify (manual, live): see Manual testing and validation. - -*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: Phase 4 is filed on the dotfiles side, waiting on them -The dotfiles project accepted the Phase 4 handoff and filed it as a =[#C]= task in their own =todo.org= (their note, 2026-07-08 16:56): the help-text audit + panel help affordance, the user-guide/README, and the ratio rollout doc. Not started there. They ping when it lands, and this task's Phase 4 child closes then. Nothing to do here meanwhile. - -*** 2026-08-17 Mon @ 19:57:42 -0700 Landed on the dotfiles side; the block is cleared -dotfiles shipped it as =138da7b= and closed its own task, so this one closes -with it and the =:blocked:= tag comes off. Found by checking their =todo.org= -rather than waiting for the ping — their close-out note says "archsetup pinged -so its Phase 4 task can close", so the handoff worked and only this end was -left open. - -All three acceptance criteria are met on their side: the help audit found and -fixed a stale =net repair= action list (nine of nineteen actions were named; -both the CLI help and =repair.py='s docstring now generate from the ACTIONS -registry), =net/README.md= covers every command plus the recovery targets, and -the ratio rollout is documented with both daily drivers verified current. - -They split the panel help affordance out rather than inventing it — no sibling -panel has one, so its shape is a design call. It is tracked on their side, not -here. - -Original deliverable, for the record: in-app help (=net --help= + per-command, -panel help affordance); README/user-guide; archsetup Hyprland dep install -(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep + -stow step. Handed off 2026-07-04 with the archsetup deps already confirmed -installed. - -*** TODO Phase 5 — VPN / WireGuard CLI fold (vNext) :network: -Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up. +Carved out of the =custom/net= umbrella when that closed on 2026-08-21. ** TODO [#B] Local offline LLM runtime + per-host model cache :tooling:llm: :PROPERTIES: @@ -1641,7 +1685,7 @@ Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= ** TODO [#B] Test + CI infrastructure :test: :PROPERTIES: -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-25 :END: Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others. @@ -1713,7 +1757,7 @@ Keep test runs performant as installs and post-install tests grow (target < 2 ho :LAST_REVIEWED: 2026-05-21 :END: Proactive monitoring integrated with testing -*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing +*** TODO [#C] Fix VM cloning machine-ID conflicts for parallel testing :no-sync: :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: @@ -1724,9 +1768,9 @@ Need to investigate proper machine-ID regeneration that doesn't break networking Would enable parallel test execution in CI/CD Priority C because snapshot-based testing meets current needs -** TODO [#B] Review undeclared ratio packages for installer inclusion :chore: +** TODO [#C] Review undeclared ratio packages for installer inclusion :chore: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-08-21 :END: Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear. @@ -1775,6 +1819,30 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open - [ ] webkit2gtk - [ ] whisper.cpp +*** 2026-08-21 Fri @ 14:28:18 -0700 Dropped to [#C], and the sharper measurement is on velox now +Re-graded [#B] → [#C]. Not a change of mind about the value — nothing has been +ticked since I filed it on 2026-06-14, across two full cycles, and by my own +scheme [#B] means "this cycle" while [#C] is the parking lot. The grade should +say where it actually sits. + +The premise also moved. This list measures ratio, which carries years of +accumulated manual installs tangled up with whatever archsetup put there, so a +package being undeclared says little about whether it matters. Velox is the +better instrument now: rebuilt from archsetup on 2026-08-13 and working, so a +=make package-diff= there compares what the installer declares against what a +machine actually needs, with only days of drift on top. Re-run it on velox +before walking these forty by hand. + +Worth noting the empirical result already came in. The gaps that actually hurt +after that rebuild — rulesets never cloned, the .emacs.d systemd units never +linked, the missing =*.local.*= configs — surfaced on their own, and not one of +them is on this list. That is evidence about what this kind of list catches. + +Related but distinct: =[#B] Installed-package drift audit= below builds the tool +for the opposite direction (declared but missing, and provider substitutions). +If that lands first it plausibly subsumes the detection half of this one, leaving +only the include/ignore judgment. + ** TODO [#B] Installed-package drift audit :chore:packages:solo: :PROPERTIES: :LAST_REVIEWED: 2026-08-02 @@ -1797,7 +1865,7 @@ machine state. ** TODO [#B] Security hardening + audit :security: :PROPERTIES: -:LAST_REVIEWED: 2026-07-14 +:LAST_REVIEWED: 2026-08-25 :END: Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority. @@ -1813,12 +1881,12 @@ Umbrella for the security-hardening and audit effort. Consolidated from the 2026 **** TODO [#B] Implement port scanning check **** TODO [#B] Create security posture verification script **** TODO [#B] Set up intrusion detection monitoring -*** TODO [#B] Document threat model and mitigations within 6 months +*** TODO [#B] Document threat model and mitigations :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: Identify attack vectors, what's mitigated, what remains -*** TODO [#B] Complete security education within 3 months +*** TODO [#B] Security education :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: @@ -1881,14 +1949,352 @@ each on the live machine rather than reading the list back: The suspend work itself is untouched — no kernel parameter, no drain measurement. Only the riders moved. +*** 2026-08-25 Tue @ 11:57:48 -0600 Logged the two Aug 23 hibernate-leg failures +suspend-then-hibernate failed its hibernate leg twice on 2026-08-23 (21:06 and +23:29): "Failed to put system to sleep. System resumed again: Device or +resource busy". Noticed during the 08-24 Lua-port session and parked there; +filed here at Craig's direction so the sleep task carries it. Nothing +diagnosed yet — first step is =journalctl -b -1 -u systemd-suspend-then-hibernate= +around those timestamps to see which device reported busy. + +*** 2026-08-26 Wed @ 16:16:08 -0600 Diagnosed the hibernate battery drain: three separate faults, one task each +Craig hibernated twice in ten days and found the battery dead both times. Read +all 39 boots since the 08-13 reinstall, upower's charge history +(=/var/lib/upower/history-charge-Framewo-55-03F5.dat=, root-only, starts +08-19), sysfs, and the scripts inside =/efi/EFI/ZBM/zfsbootmenu.efi=. + +Hibernate is configured right and has worked: five hibernate+resume cycles +since reinstall (08-13, 08-17 14:31, and three suspend-then-hibernate cycles on +08-20/21). The two fatal events are the two overnight explicit +=systemctl hibernate= runs, 08-17 22:20 and 08-21 19:58. Both journals end at +"PM: hibernation: hibernation entry"; the next power-ons (08-18 10:13, 08-22 +17:22) were fresh boots whose resume hook found no image, no later swapon +reported a leftover suspend signature, and on 08-22 the battery read 2% at +power-on. The 08-23/24 night was on AC and not a battery death (three suspends +failed to enter, machine awake all night at the charge limit; the 09:57 end was +three power-key presses and a hard cut at 63%). The 08-19 death was the +caffeine/hypridle one already diagnosed. + +Three faults, tracked as the children below: +- Hibernate hard-freezes on entry (documented on Framework 13 AMD incl. Ryzen + AI 300: black screen, never powers off, intermittent, amdgpu-side). Fits + everything: the freeze precedes the swap signature, so the next boot is + fresh, and a frozen laptop at ~5 W empties 44.7 Wh in ~8 h. Unprovable from + logs by nature; the alternative (completed hibernate, unattended power-on to + the ZBM passphrase prompt) predicts a surviving image, which neither boot + had — see the VERIFY. +- ZFS ARC starves the hibernate image: "Image allocation is 8118265 pages + short" today 14:09, "390678 pages short" 08-20 09:14. ARC 58 GB of 93, + =zfs_arc_max=0= so =c_max= = RAM − 1 GiB; the kernel must free RAM − + =image_size= (37.4 GB) ≈ 56 GB. systemd falls back to s2idle and retries + every 90 min, so suspend-then-hibernate never actually hibernates. +- The SD card reader (090c:3350, =sda=, no media) can block suspend entirely: + "Freezing remaining freezable tasks failed after 20s (wq_busy=1)", pending + =disk_events_workfn= on =events_freezable_pwr_efficient=, three times on + 08-23/24. On battery that is a dead laptop by morning. + +Mistake worth remembering: =journalctl --since … -k= silently limits itself to +the current boot (=-k= implies =-b=); cross-boot kernel facts need +=_TRANSPORT=kernel= or an explicit =-b=. + +*** TODO Hibernate entry freeze — confirm under observation, then mitigate :bug:velox:hibernate: +Interim rule until this closes: do not hibernate unattended on battery. Shut +down, or suspend on AC. + +What is known: the two dead-battery hibernates match the Framework 13 AMD +"hard freeze on hibernate entry" reports (community threads 69516 and 53860, +Arch bbs 293242): screen black, power LED on, never powers off; intermittent +(one report: every 6–7 cycles); TTM/amdgpu warnings; improved by newer +=linux-firmware=; no confirmed fix. Board A9, BIOS 03.05, linux-lts 6.18.46, +=amdgpu.dcdebugmask=0x610= already on the cmdline. + +Confirm first: the "Hibernate entry freeze: five observed cycles on AC" test +under Manual testing and validation. A failed cycle there is the proof the +journal cannot give. + +Mitigations to try in order once confirmed, one at a time, re-running the +cycles after each: (1) =linux-firmware= at current, then =linux-firmware-git= +if the freeze persists; (2) =/sys/power/disk= = =shutdown= instead of +=platform= (a systemd =HibernateMode=shutdown= drop-in), which skips the ACPI +S4 path some Framework users found hanging; (3) a newer kernel (=linux= vs +=linux-lts=) for the amdgpu delta; (4) unload =mt7925e= in a pre-sleep hook +if the freeze survives the first three. Not =:solo:=: each cycle needs a +person watching the power LED. + +*** TODO ZFS ARC starves the hibernate image — cap it or shrink it pre-hibernate :bug:zfs:velox:solo: +The arithmetic: the kernel preallocates RAM − =image_size= pages before +snapshotting; with 93 GB RAM and the default =image_size= (2/5 of RAM, +37.4 GB) that is ~56 GB, and only free memory plus what shrinkers give back +counts. ARC was 58 GB today and the ZFS shrinker released little inside the +preallocation window, so it came up 31 GiB short. Nothing in +=/etc/modprobe.d/= sets =zfs_arc_max=. + +Two fixes, either or both: +- Cap the ARC: =options zfs zfs_arc_max=<bytes>= in =/etc/modprobe.d/zfs.conf= + (16 GiB leaves ~70 GB reclaimable) plus =echo <bytes> > + /sys/module/zfs/parameters/zfs_arc_max= for the running system. +- Or a =/usr/lib/systemd/system-sleep/= pre hook for the hibernate class that + lowers =zfs_arc_max=, waits for =size= in + =/proc/spl/kstat/zfs/arcstats= to fall, and restores it post-sleep. Keeps + the big ARC while awake. +- Raising =image_size= toward the kernel's ceiling (about half of RAM) also + shrinks the demand; combine with the cap. +Install it through archsetup so the next rebuild carries it (velox-only: ratio +has no swap partition). + +Verify: after the change =arcstats size= drops below the cap within seconds; +then one live suspend-then-hibernate cycle on AC with the delay temporarily +short shows "hibernation exit" and no "Image allocation … short" line in the +journal. That live cycle rides the entry-freeze test above; the ARC half is +checkable without it. + +*** TODO SD card reader media polling can block suspend :bug:velox:solo: +The reader (USB 090c:3350 Silicon Motion, =sda=, "Media removed, stopped +polling" at boot yet =events_poll_msecs= = −1 → default 2000 ms) left a +=disk_events_workfn= item pending on the freezable workqueue three times on +08-23/24, and the freezer gives up after 20 s: "Failed to put system to +sleep … Device or resource busy". Same symptom as the flaky expansion slot in +the ribbon task; a stalled poll never completes. + +Fix: a udev rule for that vendor/product setting +=ATTR{events_poll_msecs}="0"= (or =block.events_dfl_poll_msecs=0= on the +cmdline if every removable disk should stop polling), shipped by archsetup. +Verify with =rtcwake -m mem -s 20= on AC: journal shows "PM: suspend entry" +and "PM: suspend exit" with no "Freezing remaining freezable tasks failed", +and =/sys/block/sda/events_poll_msecs= reads 0 after a replug. Pulling the +card before sleeping is the manual workaround meanwhile. + +*** VERIFY After the 08-17 and 08-21 dead batteries, did the first power-on hang, or boot straight to a fresh login? +Decides between the two mechanisms. An entry freeze leaves no image, so the +next power-on boots straight through. A completed hibernate followed by an +unattended power-on (phantom power button, ZBM passphrase prompt until dead) +leaves the image in place, so the next power-on would try to resume — and the +only way that ends in the fresh boots the journal shows is a hung resume that +got force-cut. If both power-ons went straight to a fresh login, the freeze +is the answer. + +** TODO [#A] Port Hyprland config to Lua before 0.57 drops .conf support :hyprland:dotfiles: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:LAST_REVIEWED: 2026-08-24 +:END: +Hyprland prints "You are using the .conf config format, support for which will be +removed in Hyprland 0.57" at every start. Installed and in =extra= is 0.56.2-1, so +the *next* release breaks the config. Craig's call 2026-08-24: port now, under no +time pressure, rather than pin the package or wait for the upgrade to force it. + +STATE (2026-08-24): built and verified in a nested compositor, *not deployed*. +I deployed it to the dotfiles tree this afternoon and rolled it back the same +hour on Craig's call — the switch had not been checked on real hardware and the +machine has to stay usable. The dotfiles repo is untouched at =8f692f5= and the +live config is the original =hyprland.conf=; =hyprctl reload= after the rollback +returned zero configerrors and the velox host override is applied +(=xwayland:force_zero_scaling= false), so the per-host chain is intact. + +Everything needed to redeploy is in =working/hyprland-lua-port/= with a README +carrying the step-by-step: the three =.lua= deliverables, plus the two reader +changes saved as patches (=reader-changes-for-lua.patch= for dotfiles' +=dotfiles-validate= and three test suites, =test-desktop-for-lua.patch= for +archsetup's post-install checks). Both patches were verified to apply clean +against their repos, and every assertion in them was mutation-tested — each one +confirmed to go red when the property it guards is removed. Replay them rather +than rewriting the assertions. + +Also settled along the way: =themes/dupre/hyprland.conf= is dead. Nothing sources +it, no apply script exists, and it has silently drifted from the live config +(=dab53dff= / =2c2f32ff= against =daa520ff= / =444444ff=). It needs no porting. + +HOW IT WAS BUILT. =hyprlang2lua= (github.com/EIonTusk/hyprlang2lua, AUR 0.7.1-1) +converts hyprlang to the 0.55+ Lua format and preserves comments. Built from +source into a scratchpad with the local Go rather than installing the AUR package +— one dependency, and no PKGBUILD executed. Run with =--no-merge=, which emits +each config section as its own =hl.config()= call at its original position; the +default merges them into one hoisted call, which both scrambles comment placement +and puts the =conf.d= source glob BEFORE the config it must override. + +THREE DEFECTS THE CONVERTER INTRODUCED, all fixed by hand: +1. Source glob emitted before the merged config block, silently reversing every + per-host override — including velox's =force_zero_scaling = false=, which is + the 2026-08-19 Qt scaling fix. =--no-merge= plus moving the glob to the last + line fixes it. +2. =bind = CTRL $mod, S= became ="CTRL" .. mod .. " + S"= → ="CTRLSUPER + S"=; + same for =CTRL ALT $mod, K=. Proven fatal, not merely odd: Hyprland answers + =hl.bind: failed to parse key string: Unknown keysym: "CTRLSUPER"=. Two dead + keybinds. +3. Super+RETURN is an =exec= of a shell pipeline; the converter pattern-matched + the =hyprctl dispatch layoutmsg= prefix and swallowed =&& sleep 0.05 && ...= + as the layoutmsg argument. That sleep is the one proven load-bearing 19/19. + +Also rebuilt the autostart section: the generator hoists every =exec-once= into +one block at the end and leaves the comments stranded where the commands were. +Each command now sits under its own comment again via =at_start= / =at_shutdown= +/ =at_reload= collectors that the =hl.on()= handlers at the bottom replay. + +VERIFIED by running both configs in a nested Hyprland on a headless output and +diffing runtime state, not by reading: 38 config keys identical (only type +*rendering* differs — =bool: true= where hyprlang prints =int: 1=); +=xwayland:force_zero_scaling= false on both sides, so the host override still +wins; 103 binds registered on both sides with 100 of 103 matching exactly; +autostart list byte-identical to the 16 =exec-once= lines in order; zero +=configerrors=; and no ERR/WARN line in the ported run that is absent from the +original run. + +KNOWN DELTA — the three =bindm= binds. hyprlang reports =mouse: true=, the Lua +path reports =mouse: false=, and the raw bind struct confirms the flag is unset +rather than merely unreported. Not a transcription error: the wiki documents +exactly the spelling used (=hl.bind("ALT + mouse:272", hl.dsp.window.drag(), +{ mouse = true })=), and all four candidate spellings were tested — none sets it. +Reads as a gap in 0.56.2's Lua config manager. Kept the documented spelling: it +is correct upstream, harmless now, and starts working when the gap closes. Per +the wiki that flag is what makes the action fire *while held*, so Super+drag to +move a window may fire once instead of tracking. Settle it in five seconds after +switching; worth an upstream report if it survives 0.57. + +WHAT REMAINS: +1. *Craig decides when to switch.* The port is ready to go in; it has not been + run on real hardware. The gate is the "Hyprland Lua config" test under Manual + testing and validation, which is written to be run right after the switch. +2. *Four review gates travel with the redeploy*, all written up in that README: + exclude any in-repo =retired/= dir from =dotfiles-validate= (its find globs + across slashes and would validate the dead config); add tests for the new + =dotfiles-validate= Lua branch (25 lines, currently zero coverage — proven + vacuous, since stubbing both regexes to =NEVERMATCHES= still passes 15 tests); + guard that =hl_source_glob= stays the last statement (the one invariant the + per-host layer rests on, and archsetup's VM cannot catch it); and sweep the + ~15 prose comments still naming =hyprland.conf=, of which + =waybar-reserve:12= is the load-bearing one. +3. *Redeploy per =working/hyprland-lua-port/README.org=*, which carries the eight + steps and the two traps that bit on 2026-08-24: =make restow hyprland= aborts + on the pre-existing =obsbot-wb-guard.service= conflict in =common= (restow + =hyprland= and the host package individually), and the running Hyprland + rewrites a stub =hyprland.conf= within a second of the symlink vanishing + (silence it with =hyprctl keyword misc:disable_autoreload 1=, stow, set back + to 0). +4. *Push dotfiles before committing archsetup.* One-directional and load-bearing: + archsetup's post-install suite asserts =~/.config/hypr/hyprland.lua= and the + installer clones the dotfiles *remote* (=archsetup:1481=), so a local commit is + not enough. Confirm with =git ls-tree -r origin/main --name-only | grep + hypr/hyprland.lua=. +5. *Do not leave the =.conf= beside the =.lua= as a rollback.* With both present + Hyprland 0.56.2 loads the =.lua= — proven in a nested instance with a fixture + whose =.conf= set =gaps_in=11= and =.lua= set =77=; the result was 77. A + =.conf= left in place buys nothing and only obscures which file is live. Move + it out of the stow package instead. +6. *=bindm='s missing =mouse= flag is worth an upstream report* if it survives + 0.57. Documented spelling, four variants tested, flag never set. + ** TODO [#B] Manual testing and validation :test: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-08-23 :END: -Craig's standing checklist of everything that isn't agent-verifiable. Each child is one test in the =verification.md= shape (title, what we're verifying, steps, Expected). A child that fails gets its actual behavior written under it and is promoted to a top-level TODO. 44 checks pending as of the 2026-07-09 audit. +Craig's standing checklist of everything that isn't agent-verifiable. Each child is one test in the =verification.md= shape (title, what we're verifying, steps, Expected). A child that fails gets its actual behavior written under it and is promoted to a top-level TODO. 62 checks pending as of the 2026-08-23 review — up from 44 at the 2026-07-09 audit, so the queue has gained 18 in six weeks and nothing has drained it. A checklist that only grows is on its way to being where tests get filed rather than run; if the next review finds it higher again, the container needs a scheduled sweep rather than another re-stamp. Priority and type tag added by that audit: the task carried neither, which kept the project's largest live container out of the agenda entirely. +*** Hyprland Lua config: does the real desktop come up, and does Super+drag track? +What we're verifying: that the Lua port drives a real Hyprland session the way +the .conf did, and specifically whether the one known delta — the three =bindm= +binds losing their =mouse= flag — actually costs anything. A nested compositor +proved 38 config keys, 103 binds and the host-override chain identical, but it +cannot test real input devices or a real DRM display. + +PRECONDITION: run this *only after* redeploying the port per +=working/hyprland-lua-port/README.org=. As of 2026-08-24 the port is rolled back +and the live config is the original =hyprland.conf=, so running this now just +confirms the old config — which is not what it is for. Run it on velox; the stub +check in the last block is velox-specific. +- Restart Hyprland (log out and back in, or =hyprctl dispatch exit= from a TTY). +- Confirm the desktop comes up: waybar present and not off-screen, wallpaper + restored, dunst notifications working. +#+begin_src sh :results output +# Which config did it actually load, and did anything fail to parse? The log is +# per-instance under the runtime dir, not in ~/.local/share. Resolve the newest +# instance dir rather than reading $HYPRLAND_INSTANCE_SIGNATURE: Emacs runs as a +# daemon that survives the logout in step 1, so a block run from it can still be +# carrying the PREVIOUS session's signature. That path is gone after the restart, +# grep prints nothing, and an empty result under "Expected: names hyprland.lua" +# reads as "the port failed" when it in fact succeeded -- the worst possible +# wrong answer at exactly the wrong moment. +log="$(\ls -td "$XDG_RUNTIME_DIR"/hypr/*/ | head -1)hyprland.log" +echo "reading: $log" +grep -iE '\[cfg\].*(lua|legacy)' "$log" | tail -3 +hyprctl configerrors +#+end_src +Expected: the log names hyprland.lua, and configerrors is empty. +- Hold Super and drag a window with the left mouse button. +Expected: the window tracks the pointer continuously while Super is held. If it +jumps once and stops, the =bindm= =mouse= flag gap is real and costs the drag — +write that here, promote to a top-level TODO, and report upstream. +- Hold Super and drag with the right mouse button (resize), same check. +- Walk the keymap: the launcher, terminal, browser, screenshot chords, the panel + family (Super+Shift+B for bluetooth), workspace switching, layout cycling. +Expected: every chord does what it did before the port. +#+begin_src sh :results output +# The stub .conf should stay gone now that Hyprland started from the .lua. +# Refuse to touch a symlink: on a host that has not been through this port yet, +# ~/.config/hypr/hyprland.conf is still the stow link to the real config, and +# deleting it would report "stays gone" as a pass while having broken the desktop. +f=~/.config/hypr/hyprland.conf +if [ -L "$f" ]; then + echo "REFUSING: $f is a symlink (a live stowed config), not the stub." +elif [ -f "$f" ]; then + rm -f "$f"; sleep 2 + [ -e "$f" ] && echo "REGENERATED — still stubbing" || echo "stays gone" +else + echo "already absent — nothing to do" +fi +#+end_src +Expected: "stays gone". If it regenerates, Hyprland is still resolving its config +to the .conf path and the port is not actually live — stop and investigate. + +*** Lock screen after a real sleep: is the clock frozen, or is all of hyprlock frozen? +What we're verifying: which of two very different faults produces the stale +lock-screen clock Craig sees after waking the machine. Three isolated tests on +2026-08-24 all failed to reproduce it — hyprlock v0.9.6 repainted correctly +within one second of a display power-cycle, of a three-minute SIGSTOP, and of +both together with the =path = screenshot= background. So the cause needs a real +suspend on real AMD hardware, which only Craig can run. + +The distinguishing question is whether *anything* on the lock screen is alive. +A frozen clock with live input is a rendering bug in the clock. A frozen clock +with dead input means hyprlock is hung and the compositor is simply holding its +last pre-suspend frame — a different bug, in the crash/hang recovery layer. + +Run this the next time the machine has actually slept (30 min idle, or a lid +close), not on a screen you just locked by hand. + +- Wake the machine and do NOT type your password yet. +- Read the clock. Note the time it shows and the real time. +- Type one character into the password field. +Expected (record which): either the dots appear normally — input is alive, only +the clock is stale — or nothing appears at all, in which case hyprlock is hung. +- Keep watching for a full 90 seconds without touching anything else. +Expected (record which): the clock corrects itself to the current time within a +minute, or it never changes at all. +- Note whether the blurred background looks like your desktop or like a + previous lock screen (a lock screen photographed behind a lock screen means a + second hyprlock was started on top of the first). + +Expected: enough to pick one of three — stale-then-corrects (a repaint-interval +fix), frozen-with-live-input (a rendering fix), or frozen-with-dead-input +(hyprlock hung; the =screen-lock= watchdog only catches a hyprlock that +*exits*, and its own header says it is blind to one that hangs). + +*** Lock keybind has no crash or hang recovery +What we're verifying: that a hand-lock is as recoverable as an idle lock. +=hyprland.conf:495= is =bind = $mod, ESCAPE, exec, hyprlock=, which runs the +binary directly. hypridle's =lock_cmd= routes through =screen-lock=, which +relaunches a hyprlock that exits non-zero; the keybind bypasses that entirely. +=~/.local/var/log/screen-lock.log= does not exist on velox, so the watchdog has +never recorded a relaunch — consistent with it rarely being in the path at all. + +- Lock with Super+Escape. +- From another tty (ctrl+alt+F3), log in and run: =pkill -x hyprlock= +- Return to the graphical tty. +Expected: with the keybind as written, the session is left locked with no client +and Hyprland draws its "lockscreen app died" screen. If instead a fresh password +prompt appears, something is already relaunching it and the gap is closed. + *** Clock/DNS deadlock: does the next abrupt power loss strand velox again? What we're verifying: that the machine survives an RTC reset unattended. Not the coin cell, which is new with the 2026-08-13 mainboard and is ruled out. The RTC @@ -2507,6 +2913,32 @@ NOTE (2026-07-04 audit): the "four-tab panel" framing predates the instrument-co - Expected: ↓/↑ Mbps + ping + server shown inline. - Byte-rate→Mbps unit: VERIFIED 2026-06-30 (velox). Raw =speedtest-go --json= dl_speed read ~3.66M, unambiguously bytes/s (29 down / 80 up Mbps); =net speedtest= reported 33.62 / 77.99 through the wired path. =BYTES_PER_SEC = True= + =* 8 / 1e6= are correct, no flip needed. Remaining here is only that the panel renders the inline result. +*** Hibernate entry freeze: five observed hibernate cycles on AC +What we're verifying: whether velox hard-freezes on hibernate entry (black +screen, power LED on, never powers off), the documented Framework 13 AMD +failure that fits both dead-battery events. The journal cannot show it; a +person watching the LED can. +- Plug in AC, lid open, nothing important unsaved. +- Note the cycle number, then hibernate from a terminal: +#+begin_src sh :results output +date; systemctl hibernate +#+end_src +- Watch: the screen goes black; within about two minutes the power LED goes + off and the fans stop. +- Press power, enter the ZBM passphrase, and confirm the same session comes + back (windows still open). +- Check that the cycle was a real hibernate and not a fallback: +#+begin_src sh :results output +journalctl -b -o short-iso | grep -E "systemd-sleep|hibernation (entry|exit)|Image allocation|Failed to put" | tail -6 +#+end_src +- Repeat until five cycles are logged. +Expected: all five cycles power off within two minutes and resume into the +same session, with "hibernation exit" and no "Image allocation … short" line. +A cycle where the screen stays black with the power LED on for more than five +minutes is the entry freeze: hold power for 10 s, and write down the cycle +number and whether the keyboard backlight was lit. A cycle that instead comes +straight back with "Cannot allocate memory" is the ARC task, not a freeze. + ** DOING [#B] Prepare for GitHub open-source release :PROPERTIES: :LAST_REVIEWED: 2026-08-17 @@ -2637,9 +3069,9 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst *** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change. -** TODO [#B] The audio doctor never checks the microphone :bug:audio: +** TODO [#C] The audio doctor never checks the microphone :bug:audio: :PROPERTIES: -:LAST_REVIEWED: 2026-07-13 +:LAST_REVIEWED: 2026-08-25 :END: The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have. @@ -2649,13 +3081,15 @@ Work: mirror the sink rules onto the source. =probe_semantic= gains =default_sou Two things not to get wrong. An absent microphone is legitimate on a desktop, so "no input devices" must never be a fault the way =no-output-devices= is. And a monitor source is a legitimate default source (recording desktop audio), which is why =probe_semantic= passes =include_monitors=True= — inheriting the panel's display filter here would call a working setup broken. -Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, four decisions open). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix. +Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, three decisions open as of 2026-08-25). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix. Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it. +Grading (2026-08-25 review): Major severity — the doctor's verdict is silently wrong for a whole direction, workaround is checking the mic by hand — × "some users, sometimes" (mic faults are occasional) = P3 = [#C]. Was held at [#B] ungraded; regraded by the matrix. + ** TODO [#C] Weather chip color signals unclear + unenforced :bug:dotfiles:waybar:weather: :PROPERTIES: -:LAST_REVIEWED: 2026-07-21 +:LAST_REVIEWED: 2026-08-26 :END: From the roam inbox (2026-07-20): the shipped Waybar weather chip's comfort coloring reads as noise — it shows amber for no clear reason, and some items are bolded, which isn't a legible signal. Craig's intended scheme (every item except the arrow key colored by whether the weather is comfortable; NO bold or italic anywhere): - Normal — all text white: temp in 60-85; condition sunny/clear/etc. @@ -2704,7 +3138,7 @@ manual-testing entry; everything else verifies with fakes. ** TODO [#C] Net panel speedtest history :feature:dotfiles:network: :PROPERTIES: -:LAST_REVIEWED: 2026-07-14 +:LAST_REVIEWED: 2026-08-25 :END: From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues. @@ -2720,13 +3154,13 @@ Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= ex ** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar: :PROPERTIES: -:LAST_REVIEWED: 2026-07-14 +:LAST_REVIEWED: 2026-08-26 :END: From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call. ** TODO [#C] Net panel: driver-health diagnostic tier :feature:network: :PROPERTIES: -:LAST_REVIEWED: 2026-07-14 +:LAST_REVIEWED: 2026-08-26 :END: Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer. @@ -2860,1152 +3294,265 @@ Verified rather than assumed: =~/.telega/telega-server.log= carries zero =tdat_p Re-graded =[#C]= → =[#D]= per the bug matrix. There is no defect to fix here; it is a watch item with a named tell, and the severity × frequency read is cosmetic (host coredump noise on a metric we own) × rare edge case → P4 → =[#D]=. It stays on the list only so the tell isn't lost. The maintenance console's coredump metric flagged telega-server on ratio (8 coredumps) and velox (18). Root cause was a version skew: the Dockerized =zevlg/telega-server:latest= is frozen at the 2026-06-05 build while the installed elisp lagged at 20260513, so the newer server's plist parser choked on the older elisp's output. .emacs.d fixed it by upgrading telega to 20260706 on both machines (docker kept, =docker pull= is a no-op against the frozen image). Host-coredump pollution should stop. If zevlg later pushes a =:latest= that outruns the installed elisp, the skew and the coredumps recur — the tell is a fresh =tdat_plist_value:500= assertion in =~/.telega/telega-server.log=. The durable escape is a host-native pinned TDLib build, at the cost of an AUR source build. -* Archsetup Resolved - -** DONE [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs: -CLOSED: [2026-07-19 Sun] -Recovered. Velox boots linux-lts 6.18.38 and is back on the tailnet (up 1d+, /boot holds initramfs-linux-lts.img). The pre-pacman ZFS snapshot rollback restored the kernel from the ZBM recovery shell. -Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it. - -Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot. - -Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it. - -Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R): -#+begin_src sh -# 1. pool writable + key loaded -zpool get readonly zroot -zfs get -H -o value keystatus zroot/ROOT/default -# if readonly=on: zpool export zroot && zpool import -f -N zroot -# if keystatus=unavailable: zfs load-key zroot - -# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time) -zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman - -# 3. see current /boot state (read-only mount) -umount /mnt/be 2>/dev/null; mkdir -p /mnt/be -mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be -ls -la /mnt/be/boot - -# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name -umount /mnt/be 2>/dev/null -zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R - -# 5. VERIFY before reboot — remount RO, confirm the kernel is back -mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be -ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img -umount /mnt/be - -# 6. only once /boot shows a kernel: -zpool export zroot && reboot -#+end_src -Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-velox-boot-failure-handoff.org=; ZBM photo: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-PXL_20260715_043758976.jpg= (local on ratio; inbox is gitignored). -** DONE [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick: -CLOSED: [2026-07-19 Sun] -Shipped dotfiles 9dfe082: date-only ring (ordinal/full/longdate), on-scroll rewired, layout guard flipped. UTC/time stay on the time module. -Date and time are separate fixed-position controls. The time display cycles its -own formats, including UTC; the date/calendar control cycles date-only formats -and never displays a second time. Implement the dedicated format rings, -tooltip behavior, and tests together in the dotfiles Waybar configuration. -Reference material for the compact clock/chronograph treatment is filed in -[[file:working/clock-display-references/][working/clock-display-references/]]. - -*** 2026-07-19 Sun @ 04:36:26 -0500 Folded clock-panel interaction direction -The clock-panel handoff settled the prior open question: UTC belongs only to -the time ring, while the date ring is date-only. The existing task is therefore -a focused follow-up, not a two-line restoration of the old combined ring. -** DONE [#C] Notification sound loudness :chore:audio:quick:solo: -CLOSED: [2026-07-19 Sun] -Shipped dotfiles 808ca23: NOTIFY_VOLUME default 65536->39322 (0.6 gain) in both notify copies. -Reduce notification-sound playback loudness by 40% (0.6 gain, approximately --4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding -the normalized sound files; verify each notification type still plays clearly. -** DONE [#C] Show the active wired interface in the Waybar network module :feature:waybar:network: -CLOSED: [2026-07-19 Sun] -Shipped dotfiles 22867f9: select_device prefers connected wifi -> connected ethernet -> wifi fallback, so a live cable shows the wired glyph+iface instead of Offline. -When Ethernet is active, replace the offline-WiFi presentation with the wired -interface glyph and interface name. -** DONE [#C] Let the clock panel dismiss itself on right click :feature:clock:waybar: -CLOSED: [2026-07-19 Sun] -Shipped dotfiles fc9a2b7: secondary-button gesture -> ClockApplication._dismiss hides the open panel. Live-verified with Craig 2026-07-19. -Make a right click inside the open clock panel toggle it closed. Preserve left -click for its established interaction; the Waybar time module remains the -explicit way to reopen the panel. -** DONE [#C] Make the WiFi toggle connect the best available profile :feature:network: -CLOSED: [2026-07-19 Sun] -Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect. -When enabling WiFi, automatically connect to the highest-priority available -saved network instead of requiring a panel selection first. -** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network: -CLOSED: [2026-07-20 Mon] -Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B]. -Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys. -Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only). -Follow-ups filed below: harden cgit exposure; installer no longer ships configs. -** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success. -Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C]. -archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2). -** DONE [#C] Installer nvme early module never built into initramfs :bug:solo: -CLOSED: [2026-07-20 Mon] -Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py. -Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C]. -archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3). -** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py. -Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C]. -archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1). -** DONE [#C] Installer run_step state + exit-code handling :bug:solo: -CLOSED: [2026-07-20 Mon] -Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py. -Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C]. -archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1). -** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail: -CLOSED: [2026-07-20 Mon] -Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed. -Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C]. -scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4). -** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py. -Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C]. -archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2). -** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network: -CLOSED: [2026-07-20 Mon] -Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests. -Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C]. -scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4). -** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo: -CLOSED: [2026-07-20 Mon] -Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq. -Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C]. -scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5). -** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true. -Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C]. -tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6). -** DONE [#C] Add inetutils to install base :feature:solo:quick:network: -CLOSED: [2026-07-20 Mon] -Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification. -Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751. -** DONE [#D] Installer resume-idempotency cluster :bug:solo: -CLOSED: [2026-07-20 Mon] -Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py. -Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots. -crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3). -** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py. -Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D]. -With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3). -** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg. -Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D]. -scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4). -** DONE [#D] VM test-framework robustness cluster :bug:test:solo: -CLOSED: [2026-07-20 Mon] -Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin). -Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit. -scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5). -** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick: -CLOSED: [2026-07-20 Mon] -Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib). -Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor. -docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7). -** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo: -CLOSED: [2026-07-20 Mon] -Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned. -Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit. -scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7). -** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo: -CLOSED: [2026-07-21 Tue] -Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence). -Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B]. -archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3). -** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles: -CLOSED: [2026-07-21 Tue] -Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste. -Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule. -** DONE [#B] Build: desktop-settings panel :feature:hyprland:dotfiles: -CLOSED: [2026-07-22 Wed] -:PROPERTIES: -:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce -:END: -The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there. -*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237 -Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/. -*** 2026-07-22 Wed @ 15:26:44 -0500 Built the presenters (phase 2) — dotfiles 5172289 -Three GTK-free models per prototype 37, all at 100% line coverage (tests/settings/test_presenters.py, 100 tests; full repo suite green before and after). programs.py: the matrix — eight complete programs (Craig's four factory scenes drafted here per the pre-flight pick, slots 1-4 first-class), pin rows + power radio row, activate returns the full sets, member writes return apply/updated with active-is-live surviving. bench.py: drum mapping (screen never reads 0, floor 5%; kbd floors at 0), idle rail order clamping between enabled neighbors, park/unpark with re-clamp, caffeine bypass, view-state builder tolerant of no-backlight None. channels.py: the eight-channel bank, per-mode sources visibility, alpha/recency sort (unlabeled last), the shared mint/edit/delete grammar for pairs/sets/colors (press arm-cycle, two-picture set minimum, dup rejection, selection clamping), sources guardrails, interval wheel, previews. Handoff note in ~/.dotfiles/inbox/. -*** 2026-07-22 Wed @ 16:03:52 -0500 Ported prototype 37's instruments to GTK (phase 3) — dotfiles 33d82eb -The panel renders P37 end to end. New instruments.py carries the three Cairo instruments as clock-free humble objects: ProgramMatrix (glyph/numbered heads over jewel pins + CPU POWER paper letter wheels), DrumRoller (paper drums, drag-to-set, dimmed n/a on no-backlight machines), TripDial (sqrt 300° scale, colored stage tabs, OFF-notch parking, exact-minutes drag counter, BYPASSED · CAFFEINE stamp, bottom legend). gui.py rebuilt to P37's layout with the wallpaper sub-view: channel bank with drawn faces, minted pair/color/set trays (alpha/time sort, edit/delete chip feet), the three presses (pair arm-cycle, color picker, set press + interval wheel), sources with a folder picker. New GTK-free glue all unit-tested (test_panel_glue.py, 33 tests): dial geometry in bench, matrix/idle/wallpaper wiring in panel, presenter-vocabulary channels (pair/solid/random-from-set) in wallpaper.apply. AT-SPI smoke (make test-panel-settings) drives the real wiring against faked backings + a sandboxed store, pinned to its own child pid so it can never fire a live panel's backings. Visually verified on a headless output against P37 captures (main + pair/single/solid/random). Adaptations recorded in the handoff: five-stage dial (WATCH gets its own green — the engine runs watch separately, P37 merged the label), DESKTOP_SETTINGS_START_VIEW test seam. Full suite 84 suites green; window rule widened for the 540px panel. Handoff note in ~/.dotfiles/inbox/. -*** 2026-07-22 Wed @ 16:47:54 -0500 Integrated phase 4 — dotfiles 680b50d -Bar consolidation had landed early (74f723e); this pass shipped the rest. settings-project hosts the watch/clock/world channels as HTML faces (settings/faces/) on a gtk-layer-shell background window over WebKit2 — all three visually verified on a headless output, world reading the waybar worldclock roster via query param. settings-watch is the hypridle watch-stage host: throwaway-profile chrome kiosk that reveals only after its window maps behind the lock and relocks before teardown — a failed face degrades to the plain lock, never a bare desktop (unlocked lifecycle verified live; the locked swap goes to the manual checklist). Sun-pair location reads whereami live per transition with last-good cache in state.json (verified live: 9.5s first beat, New Orleans coords, Gogh day side applied); desktop-settings-tick.timer (2 min, enabled on ratio, added to the installer) drives flips and random draws — 23ms no-op beats. dunstrc history_length 100 protects held alarms (full DND cycle verified against live dunst; wtimer alarms already CRITICAL via the notify wrapper, no promotion rule needed). Live hypridle rewrite verified — five-stage regime rendered through the stow symlink, caffeine respected (found engaged, daemon correctly left stopped). Refresh signals needed no rewiring (touchpad signals itself via toggle-touchpad). 45 new tests; suite 84 suites green; smoke 13/13. Handoff note in ~/.dotfiles/inbox/. -Velox one-time steps (sync doesn't carry): mpvpaper (AUR), optionally power-profiles-daemon (service off), and systemctl --user enable --now desktop-settings-tick.timer. -*** 2026-07-22 Wed @ 17:05:58 -0500 Landed the 17-point end-to-end pass — dotfiles 9038eee -Prototype 37's 17-point suite re-derived against the real panel (the original Playwright script wasn't preserved; the functional surface in the spec's Final prototype section is the source). tests/settings/panel_e2e.py + run-panel-e2e.sh + =make test-panel-e2e=: points 1-14 drive the running panel over AT-SPI (program recall with per-backing verification across FOCUS/BATTERY/slot1, pointer console keys, all eight wallpaper channels including projected watch/world stop/start ordering, close); points 15-17 cover the Cairo instruments (drums, tripper dial clamp/park/render/reload, matrix pins + letter wheels with active-is-live) at the backing layer, since AT-SPI can't reach a DrawingArea's hit-tests. Same safety posture as the smoke: sandboxed store, faked backings, pid-pinned a11y node. 17/17 green on ratio's live compositor; full suite 85 green; smoke 13/13; ruff clean. The drag gestures go to the manual checklist below. Handoff note in ~/.dotfiles/inbox/. -*** 2026-07-22 Wed @ 17:05:58 -0500 Flipped the spec to IMPLEMENTED -docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits (dotfiles 7a15237 / 74f723e / 5172289 / 33d82eb / 680b50d / 9038eee) and the verification evidence (85 suites, smoke 13/13, e2e 17/17). The four panel drag-gesture checks and the locked-path night-watch swap live under "Manual testing and validation" — human-eye checks, not implementation blockers. -** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland: -CLOSED: [2026-07-21 Tue] -Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving. -Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix. -A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo). -Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it). -Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync). -** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network: -CLOSED: [2026-07-21 Tue] -Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task. -scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). -** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: -CLOSED: [2026-07-21 Tue] -Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green. -Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit. -The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run. -** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then. - -WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock). - -ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility. - -THE FIX, four layers, research-validated: -1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot. -2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines. -3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer. -4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice. - -REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=. - -PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=. - -Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion. - -Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio. -** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it. - -SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates. - -THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate. - -THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES: - -1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages. - -2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses. - -3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive. - -AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount. - -ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads. - -The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution. -** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red. - -Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent. - -=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness: - -- =bt/repair.py= =repair_service_restart= -- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers) -- =net/repair.py= =repair_unmask_nm= - -So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour. - -All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.) - -THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it. - -Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember. - -NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct. -** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures). - -Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path. - -THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one. - -Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both. - -Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid. -** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller. - -Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages. - -The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing. - -The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error. - -DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME. - -Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B]. -** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2. - -Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these. - -THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling. - -1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source. - -2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state. - -Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can. - -WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0. - -Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C]. - -THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks. -** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red. - -Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low. - -Three defects, all of which make the script's final word untrue: - -1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0. - -2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive. - -3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions. - -Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe. - -Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C]. - -Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message. -** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1. - -Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees. - -Two defects, both verified by running the script rather than argued: - -1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone. - -2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why. - -Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get. - -Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B]. - -Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call. -** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests. -=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code. - -=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=. - -The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent. - -Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit. - -Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D]. - -Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out. -** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure). - -Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=. - -Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued: -- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright. -- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it. -- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success. -- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither. - -Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover. - -The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks. - -Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B]. - -Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output. -** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests. -Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix. - -Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine). - -=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses. - -Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for. - -Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D]. - -Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path. -** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively). - -Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=. - -Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script. - -The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling. - -Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way. - -Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B]. - -Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect. -** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected. -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on. - -CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated. - -What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent. - -Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped. - -Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B]. - -Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file. -** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests. -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument. - -#+begin_src sh -*) - echo - "Invalid option chosen." - echo - "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ." - ;; -#+end_src - -The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only. - -Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C]. - -Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout. -** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests. -Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan. - -Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here). - -Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8. - -Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C]. - -Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache. -** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands. -Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed. - -The other 17 zones the script sets all resolve correctly, so this is the single bad entry. - -Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C]. - -Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database. -** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it. - -This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids. - -Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C]. - -Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection. -** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition. - -Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early. - -The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests. - -Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C]. - -Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe. -** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored". - -So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS. - -The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load. - -Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B]. - -Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side. -** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely. - -Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level. - -This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug. - -Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B]. - -Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite. -** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=. -Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed. - -Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry. - -Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D]. - -Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=. -** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=. - -The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it. - -Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use. - -What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header. - -Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B]. - -Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes. -** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless. - -That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there. - -CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both. - -The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment. - -Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C]. - -Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path. -** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo: -CLOSED: [2026-07-24 Fri] +** TODO [#B] Proton static WireGuard profiles pass no traffic :chore:network: :PROPERTIES: -:LAST_REVIEWED: 2026-07-24 +:LAST_REVIEWED: 2026-09-09 :END: -Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other. - -Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*. - -Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on. +wg-US-CA-144, wg-US-TX-714 and wg-NL-781 (all on wgpvpn) complete a WireGuard handshake and answer ICMP at 10.2.0.1, then forward nothing: no DNS on any transport, no HTTPS payload, no IPv6. The same account over the Proton CLI works, so the static configs are what Proton stopped honoring (the shape of an expired certificate on the profile). Diagnosed 2026-09-01. -The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone. +The net doctor now names these as a dead tunnel and brings them down (dotfiles f56fd1a), which gets the machine back online but doesn't restore the tunnels. Two ways out: re-download the WireGuard configs from the Proton dashboard and re-import them (nmcli connection import type wireguard file ...), or drop the static profiles and use the Proton CLI only. Needs the Proton account, so not solo. -Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C]. - -Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't. -** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo: -CLOSED: [2026-07-24 Fri] +** TODO [#C] Declined dot-link-restore branch untested :test:network:dotfiles:solo:quick: :PROPERTIES: -:LAST_REVIEWED: 2026-07-24 +:LAST_REVIEWED: 2026-09-09 :END: -Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach. - -Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=. - -What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it. - -Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C]. - -Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix. - -The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed. -** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=. - -=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread. - -Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits. - -What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it. - -Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C]. - -Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling. - -Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too. -** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once. - -=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol. - -Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B]. - -Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal. - -New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom. - -THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found. -** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes. - -=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot. - -The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't. - -Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C]. - -Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for. - -HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard. -** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests. -Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks. - -=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't: - -#+begin_src python -def _wp_apply(self, note="Wallpaper set"): - self._async(lambda: panel.wallpaper_apply(self.state), - lambda ok: self._toast( - note if ok is True else "nothing to apply", - good=ok is True)) -#+end_src - -=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=. - -Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D]. - -Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines. -** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end. -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing: - -- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=. -- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception. - -Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback. - -Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D]. - -Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites. -** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint. -Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=: - -- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps) -- =maint/src/maint/cache.py= =put= -- =maint/src/maint/curation.py= =_write_user= - -=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it. - -Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs. - -Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D]. - -Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision. -** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout = where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all. - -CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works. - -What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either. - -Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one. - -SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second. -** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org). - -Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call. -** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above). - -Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True. -** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C]. - -Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT. -** DONE [#B] Dotfiles tests leak state across files :bug:test:dotfiles:solo: -CLOSED: [2026-07-23 Thu] -Resolved 2026-07-23 as dotfiles =c333598=. The polluter was =tests/weather/test_weather.py=, and it accounted for all 38 failures on its own. - -The mechanism was not the env leak the body below guessed at — tests/weather never writes =os.environ=. Its whereami fake did =weather.subprocess.run = ...= on a freshly-loaded module object. The fresh module isolated the weather code, but =weather.subprocess= is the one shared stdlib module object every module in the process holds, so the assignment replaced =subprocess.run= process-wide and never restored it. Every later test file got weather's fake result back from =subprocess.run=; the tell was wtimer asserting on =r.returncode= and getting "'R' object has no attribute 'returncode'", where =R= is weather's fake result class. - -Triage: TEST HYGIENE, not production global state. The weather script reads env at import and never writes, so no long-lived-process caching defect sits behind it. A scan for the same pattern (patching a stdlib module attribute reached through another module's namespace) finds exactly one instance in the suite — the three other =setattr= sites all snapshot and restore. So the planned shared env helper across 28 files was aimed at the wrong target and wasn't needed. - -Fix: rebind the loaded module's own =subprocess= name to a stub namespace, so nothing outside that module changes and there is nothing to restore. - -Gate: =make test= now runs two gates per the add-don't-replace decision — =test-forked= (one process per file, catches order dependence) and the new =test-shared= (every suite in one process, catches leakage). Built on stdlib unittest rather than pytest, since pytest was only the diagnostic tool and isn't a project dependency. Verified as a real gate, not just green today: with the defect deliberately reintroduced it goes red, and green once restored. A focused test in tests/weather pins the invariant on the culprit as well, because the shared gate alone blames the three victim files. - -Verification: 3500 tests, both gates, exit 0. - -Original finding follows. - -Found 2026-07-23 during the speedrun. =make test= is green, but it runs each test file in its own =python3 -m unittest= process, which hides cross-file state leakage. A single-process whole-tree run (=python3 -m pytest tests/ -p no:randomly=) fails 38: 22 in =tests/wtimer/test_wtimer.py=, 10 in =tests/zoom-web/test_zoom_web.py=, 6 in =tests/wlogout-menu/test_wlogout_menu.py=. - -Not a regression — a worktree at the pre-speedrun commit produces the identical 22/10/6 profile, so this predates tonight's work. Those three files also pass cleanly when run together (170 passed), so the polluter is a fourth file somewhere in the tree that mutates global state (env var, cwd, or a module-level patch) without restoring it. 28 test files write =os.environ= directly. - -Why it matters: the green gate can't see this class of bug, so a real isolation defect — or a genuine failure that only appears under a different order — passes CI silently. Bisect by running the tree with subsets until the polluter is identified (pytest's =-p no:randomly= keeps the order stable while bisecting), fix its cleanup, then decide whether =make test= should gain a single-process pass so the gate covers it. -** DONE [#B] Wallpaper view freezes the panel — thumbnail decode :bug:dotfiles:solo: -CLOSED: [2026-07-23 Thu] -Craig reported 2026-07-23: selecting the wallpaper button freezes the module and the compositor asks whether to kill it. Root cause proven: =_Thumb._draw= decoded each source image with =new_from_file_at_scale= on the GTK main thread. Measured on Craig's 78 wallpapers — a viewport of the 8 largest takes 3.7s, the whole set 13s. That block trips Hyprland's "not responding" watchdog. - -Grading: Critical severity (panel unusable, watchdog kill) × every user every time the wallpaper view opens = P1 = [#A] by the matrix. Held at [#B] because step 1 already shipped and removes the user-visible freeze; the remainder is a latency enhancement, not a showstopper. - -*** 2026-07-23 Thu @ 15:40 Step 1 — async decode (dotfiles f45f321) -Moved the decode to a worker thread via a new =settings/thumbcache.py= (pure, injected decode/scheduler/thread; 6 tests). The thumb shows its dark ground until the pixbuf lands, then redraws. Verified live on a headless output: worst main-loop stall opening the pair view dropped from multi-second to 68ms; the cache filled with 81 decoded pixbufs (the one miss is a .webm, correctly falling back to the ▶ glyph). Full suite 3512, both gates, smoke OK. This alone fixes the reported freeze. - -*** 2026-07-23 Thu @ 16:30 Step 2 — persistent on-disk cache (dotfiles 463cc4f) -Built the persistent layer: =settings/thumbstore.py= decodes each source once to a 512px PNG under =~/.cache/settings/thumbs=, keyed by path + mtime so an edited wallpaper self-invalidates. The hot-path decode reads that PNG and scales in-memory. Warming rides the existing =settings tick= CLI verb (the 2-min timer already runs it), building up to =WARM_PER_BEAT=8= missing thumbnails per beat — best-effort, journals a line on failure, never blocks the wallpaper flip. thumbstore is pure (stat/decode/load/save injected); 10 tests. - -Went with incremental warming (8/beat, ~10 beats to full) as the safe default rather than full-warm-on-change — the per-beat cap is a one-line flip if Craig wants it faster. Measured: hot-path decode of a viewport dropped from 3.7s cold to 47ms warm. No installer change (the tick service already runs =settings tick=); cache lives outside the repo. Full suite 3522, both gates, smoke OK, live panel verified (81 pixbufs render, 48ms worst stall warm). -** DONE [#C] Panel scrollbars too short :bug:dotfiles:quick:solo: -CLOSED: [2026-07-23 Thu] -Shipped 2026-07-23 as dotfiles =0d64837= (22px scrollbar, 16px trough, 14px slider thickness with a 48px floor along the travel axis). Left open by oversight during the speedrun; closing now. - -Follow-on, and my own regression: enlarging the bar to 22px is what made it start covering the thumbnails, because nothing grew the tray to match. Craig reported it the same day ("scrollbars that obscure the images") and it's fixed in =c0ddf57= — the tray now reserves a 22px lane for the bar as a margin on the scrolled box, so the bar sits below the images instead of across them. Measured before: tray 68px, content 68px, a visible 14px bar inside the same 68px. After: tray 90, content 68, bar clear. The lane is a constant under the scrollbar CSS with a note to keep the two in step, since the coupling between bar thickness and tray height is exactly what broke. - -From the roam inbox (Craig, claimed 2026-07-23): all scrollbars need to be much taller than before. The always-visible scrollbars shipped in 7e8eb4a set =min-height: 10px; min-width: 10px= on the slider (=settings/src/settings/gui.py=, the =.dupre-panel scrollbar slider= rule) — that's the floor for a short slider, and the trough itself is thin. Raise both the slider floor and the trough thickness so the bar is comfortably grabbable. Cosmetic × every glance at the wallpaper trays = P3 = [#C]. -** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C]. - -Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log. -** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles: -CLOSED: [2026-07-24 Fri] -Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it. -** DONE [#C] Floating layout — should we? :feature:hyprland: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation. - -CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode": -- Can't select floating until at least one window is displayed. -- Entering floating freezes each window's position and floats it exactly where it is. -- During floating, drag windows with mod+mouse-drag. -- Exiting floating switches to tiling or monocle and lets that layout take over. -Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending. - -ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go. -** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task. - -Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig. -** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module. - -This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable. - -Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm. -** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable. - -Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle. -** DONE [#C] Saving and recalling window configurations :feature:hyprland: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent. - -RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept. -** DONE [#C] Velox refresh sweep :chore:maint: -CLOSED: [2026-07-23 Thu] -From the roam inbox (Craig, claimed 2026-07-23): velox needs bringing up to date, the mouse/touchpad module is still there, investigate what else didn't move over. - -Resolved 2026-07-23 by a full sweep over tailscale. The touchpad module was already gone — velox's running waybar (started 01:05, after the reboot) and its tracked config both carry zero =custom/touchpad= entries; what Craig saw was the pre-restow waybar process from before the reboot, and the reboot cleared it. Sweep results: both machines at dotfiles f9b6404 (all three hyprland lock/exit fixes live on velox, config errors clean, =allow_session_lock_restore= reads true); stow restow clean, only the expected skip-worktree files; rulesets pulled to 50fc7ca and =make install= run (agent-text verified working by invoking it — an earlier "MISSING" reading was a PATH artifact of the non-interactive ssh shell, not a real gap); desktop-settings tick timer active; mpvpaper, power-profiles-daemon, gtk4-layer-shell, webkit2gtk all present. - -Genuine remaining differences, all per-machine installs rather than sync failures: =cmail-action=, =gcalcli=, and =playwright= aren't installed on velox, and =obsbot-wb-guard.service= isn't enabled there (the OBSBOT lives on ratio). None block anything; file separately if velox should send mail or drive browser tests. -** DONE [#C] Weather tooltip sunrise and sunset :feature:waybar:weather:quick:solo: -CLOSED: [2026-07-23 Thu] -Shipped 2026-07-23 as dotfiles =de62e9d=. The two rows sit directly below Humidity in the current-conditions block, rendered in the footer's 12-hour format (=%-I:%M %p=) so the tooltip reads one way throughout. - -Confirmed the no-extra-round-trip premise held: =sunrise,sunset= joined the existing =&daily== block. Split =forecast_url= and =reading_from= out of =fetch= so both the request and the reading are testable without network — that's what let the new cases cover a payload missing the fields. Six tests (Normal/Boundary/Error): row placement and format, a pre-change cache with no sun fields, an unparseable stamp, today's pair picked out of the six-day arrays, and the API omitting them. Reused the existing =_at= helper rather than adding a near-duplicate =_first=. - -Live-verified against the real API: sunrise 6:14 AM, sunset 7:59 PM for today in New Orleans, rendering in the actual tooltip. Full suite 3506 tests, both gates, exit 0. - -Open, not blocking: every other header row carries a glyph (thermometer, droplet, wind arrow) and the sun rows are plain text. The file's glyphs are marked font-confirmed codepoints, and I haven't verified a sunrise/sunset glyph renders rather than showing tofu, so I left them bare. Craig's call. - -From the roam inbox (Craig, claimed 2026-07-23): in the weather module's hover text, the section immediately after the location ends with the current humidity. Add the sunrise and sunset times for the current location directly below it. - -Cheap to source: the module already calls Open-Meteo with a =&daily== block (=common/.local/bin/weather=, the forecast URL around line 336), so =sunrise,sunset= joins that same request with no extra round trip — normalise_daily already parses the daily arrays. Times arrive as local ISO strings; render in Craig's canonical clock format rather than re-deriving one. The settings package's =suntimes.py= (pure NOAA math, no network) stays the offline fallback path if the API field is ever absent — don't duplicate its math here. -** DONE [#C] Maint doctor-row copy button :refactor:maint:quick:solo: -CLOSED: [2026-07-23 Thu] -Shipped 2026-07-23 as dotfiles =761fa5c=, "fix(maint): drop the COPY key from the doctor row" — the key and its orphaned handler removed from =maint/src/maint/gui.py=. =viewmodel.status_copy_text= stays: it's a tested pure serializer and the obvious source if a copy surface returns somewhere better placed. - -Correction to the body below: it describes a per-row button and a separate global one. There is only one COPY key, and it IS the global one Craig added in 8bc79ba two days earlier. He tried it and wanted it gone, so the row now reads DOCTOR · CLEAN UP · REVIEW & FIX. - -From the roam inbox (Craig, claimed 2026-07-23): remove the per-doctor-row copy button (next to REVIEW and FIX) from the maint status wall. The global COPY key (dotfiles 8bc79ba, "one global button copying rendered text") stays the one copy surface — the per-row button turned out to be clutter next to it. -** DONE [#C] WiFi tooltip signal strength :feature:waybar:network: -CLOSED: [2026-07-22 Wed] -From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip. - -Resolved 2026-07-22: the tooltip's signal line existed but never fired on ratio — the mt7925 driver leaves /proc/net/wireless empty (legacy WEXT procfs unimplemented), so the dBm read returned None and the bar glyph fell to the weakest tier. Fix in dotfiles net/: an iw-dev-link nl80211 fallback (only spawns when procfs is empty), a signal_percent mapping, and an enriched line — Signal: ▂▄▆█ 100% · -32 dBm (excellent) — bars by band, percent, raw dBm, band word. The bar icon tier fixed itself as a side effect. -** DONE [#B] Desktop-settings dropdown panel :feature:waybar: -CLOSED: [2026-07-22 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-22 -:END: -Resolved 2026-07-22: shipped end to end via the "Build: desktop-settings panel" task (dotfiles 7a15237 → 9038eee; spec IMPLEMENTED, 85 suites + smoke 13/13 + e2e 17/17). Every open question below got settled in the spec: bar consolidation landed (74f723e), the wallpaper manager became the in-panel sub-view, and the format pickers split into their own sibling spec ([[file:docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org]], DRAFT stub). Remaining human-eye checks live under "Manual testing and validation". - -Original body follows as the record. - -Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack). - -One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate: -- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it). -- *Brightness* slider (backlight, via brightnessctl). -- *Keyboard-backlight* brightness slider (brightnessctl on the kbd_backlight class). -- *Mouse* enable/disable toggle — shown only when a mouse is connected. -- *Trackpad* enable/disable toggle — shown only when a trackpad is connected (mirror =toggle-touchpad= / =touchpad-auto=). -- *Idle inhibitor* (the =custom/idle= module that replaced the built-in =idle_inhibitor= 2026-06-24 — toggles the hypridle daemon, state-synced icon). -- *Airplane mode* (the existing =airplane-mode= toggle; laptop-only). - -The conditional rows (mouse, trackpad, airplane) appear only when their hardware/context applies — reuse the laptop/device detection the airplane and touchpad indicators already do. - -Design / open questions (propose before building): -- Panel tech: sliders need a real toolkit (waybar can't host a slider), so a GTK4 + gtk4-layer-shell app like pocketbook is the likely shape. -- Which existing standalone bar modules (dim, touchpad, airplane, idle_inhibitor) collapse INTO this panel vs. stay on the bar as quick-access indicators. Craig's call. - -Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first. - -Home handoff 2026-07-19 (inbox, resolving the open "few other things" decision — fold into the spec, close the open decision, extend the controls table, then run spec-review, may flip DRAFT→READY). Ownership: home drives the build (dotfiles settings/), archsetup keeps the canonical spec. Full reconciliation in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org. -- ADD controls: night-light / color temperature; Do Not Disturb / notifications (dunst); lock / suspend quick actions; power profile (performance/balanced/saver); scenes/profiles — one control flipping several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything. -- OUT (record reasons): volume / master-mute stays with the audio panel (no mirror here); theme light/dark goes to the theme-studio task. -- FORMAT PICKERS pulled to their own future sibling spec — time/date/weather format is out of THIS panel. Rationale: format settings live in many programs, so the design problem is a single source of truth for the canonical format. Track a future sibling-spec stub in docs/specs (time/date/weather format single-source-of-truth); Craig thinking it through separately, not started. -- STILL OPEN (spec already flags): wallpaper manager confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec undecided — resolve at spec-review. -** DONE [#C] Gallery probe: the fader-drag check is flaky :bug:test:design:quick:solo: -CLOSED: [2026-07-23 Thu] -Fixed 2026-07-23. Root cause confirmed rather than suspected: =panel-widget-gallery.html= line 74 sets =html{scroll-behavior:smooth}=, so =scrollIntoView= animates and the fixed 200ms sleep sometimes read =getBoundingClientRect= mid-scroll. The drag then dispatched at stale coordinates, the press missed the fader, and the check reported a dead widget. - -Fix: scroll with =behavior:'instant'=. The probe never needed the animation, so this removes the race instead of waiting it out. Also added a =settledRect= guard (rect stable across two reads AND on-screen) for zoom/column relayout, and a =hits()= assertion that the press actually lands on the fader before the drag goes out. +repair_tunnel_dot_off (dotfiles net/src/net/repair.py) puts a tunnel link back to its DoT mode when turning DoT off didn't bring names back, and the evidence says "put back to <mode>" only when that restore succeeded. The restore-declined branch has no direct test. Give fake-resolvectl a second failure switch (FAKE_RESOLVECTL_DOT_RESTORE_FAIL) so the wording can be asserted absent as well as present. Follow-up from the f56fd1a review. -Applied to the toggle-click check too — it shares the same fixed-sleep shape, and it failed for this exact reason during the diagnosis, so fixing only the fader would have left half the defect. - -Worth recording: my FIRST fix was wrong and made it worse. Polling until the rect stopped changing returned pre-scroll coordinates every time, because two identical samples are also what you get before the animation starts — an intermittent failure became a consistent one. The new hit-test assertion is what caught it, printing the press point at y=1326 against a 1200px window. That's the argument for asserting the press landed rather than only asserting the readout moved. - -Verified against the measured 1-in-6 failure rate: 8 consecutive runs, all three checks passing, with the press point identical every run (429,480) — deterministic, not lucky. Full probe 96 PASS, 0 FAIL, exit 0. - -=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch. - -Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=. - -Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured. - -Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape. - -*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug). - -Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. - -Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). -** DONE [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:quick:solo: -CLOSED: [2026-07-23 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -Closed 2026-07-23. The last open item was the velox check, and velox is reachable again. Pulled it from =02df01a= to =de62e9d= (clean tree, fast-forward), then ran =make conflicts hyprland=, which dry-runs every tier: "No stow conflicts", exit 0. Its old conflict copy had already cleared against the updated repo, so there was nothing for =make reset= to do. - -Verified the pull is live through the symlinks rather than just present in the repo: =~/.local/bin/weather= resolves into the dotfiles tree and returns today's sun times on velox. - -Note for the record: =make conflicts common= is not a valid invocation — =check-de= rejects it, because common and the host tier are auto-included in the DE-scoped run. =make conflicts hyprland= is the whole check. -*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion) -Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo. -*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) -The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). -From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). - -Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check): -- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder. -- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there. -- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.) -** DONE [#B] Weather tooltip caching :feature:waybar:weather:solo: -CLOSED: [2026-07-25 Sat 10:53] -From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather. -Verified complete in the 2026-07-25 batch: the weather CLI already had the hourly default TTL, fresh-cache no-fetch path, stale fallback, and explicit offline footer. Its 33-test suite and the full dotfiles suite pass. -** DONE [#B] Settings gear becomes four device toggles :feature:waybar:dotfiles:solo: -CLOSED: [2026-07-25 Sat 10:53] -From the roam inbox (Craig, claimed 2026-07-23): the waybar gear should become four icons — touchpad, mouse, webcam, and a notification bubble. Clicking each toggles that setting directly. The first three turn red when disabled; the bubble turns red when DND is enabled. - -Today =custom/settings= (=hyprland/.config/waybar/config=) is one gear glyph () whose only job is =on-click: settings-panel=. The toggles themselves already exist and are tested — the settings package owns touchpad, mouse, and webcam (=webcam.py= is the USB-authorized kill switch from 2026-07-22), so this is a bar-side surface over existing backends rather than new capability. - -Note the state-polarity split when wiring the colors: three read "red = off" and DND reads "red = on". That asymmetry is deliberate (red means "something is disabled that normally isn't, or suppressed that normally isn't"), so encode it per-icon rather than deriving one rule. - -Decided 2026-07-23 (Craig): the gear STAYS alongside the four toggles as the panel launcher. So the bar's right side grows from 12 modules to 16 — the four toggles are net-new, the gear keeps its =on-click: settings-panel=. Open sub-question for build time, not blocking: whether the four toggles are four separate waybar modules or one custom module rendering four glyphs (fewer layout entries, one exec). Pick at build; the four-module shape is simplest and matches how mic/net already sit as individual modules. -Shipped in the 2026-07-25 batch as four independent JSON modules over the existing verified settings backends. Touchpad, mouse, and webcam turn terracotta when disabled; DND uses the deliberate inverse polarity; unavailable hardware dims. The gear remains the panel launcher. The live and Dupre theme CSS copies stay byte-identical. -** DONE [#C] Wallpaper panel selection and scroll state :feature:dotfiles:solo: -CLOSED: [2026-07-25 Sat 10:53] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-25 -:END: -From the roam inbox (Craig, 2026-07-25). Screenshot: =~/pictures/screenshots/2026-07-25_013041.png=. Three related behaviors in the settings wallpaper panel (=settings/src/settings/wallpaper.py=): -1. Open at the wallpaper currently displayed, not the top of the list. -2. Highlight that wallpaper as selected in the scrollable pane while it shows in the preview. -3. Keep the scroll position when a picture is selected. Today selecting a picture snaps the scroll back to the top, which is the bug half of this. -Grade: minor scroll-reset defect x every panel selection = P3 = [#C]; the open-at-current and select-current behaviors are enhancements at the same level. One type tag, so filed =:feature:= with the scroll-reset called out as the bug. Solo: buildable in the settings GTK panel, agent-verifiable via headless capture plus the wallpaper.py tests, no design call — swww query gives the current wallpaper, and scroll-position preservation and row selection are standard GTK. -Shipped in the 2026-07-25 batch. The panel queries =awww query= off the UI thread, prefers the actually displayed image over stale stored state, highlights it, scrolls it into view on first open, and remembers the horizontal adjustment across selection-triggered rebuilds. -** DONE [#C] Net tooltip IPs and line order :feature:waybar:network:solo: -CLOSED: [2026-07-25 Sat 10:53] -From the roam inbox (Craig, claimed 2026-07-23): in the wifi hover, add the internal IP, external IP, and gateway IP just below the Interface line; move the Signal line to just above the keyboard-shortcuts line. Design constraint: the bar's hot path does no network I/O (status.py deliberately skips _address_facts on the 2s beat) — internal IP + gateway can ride cheap local reads, but the external IP must come from a cache the connectivity probe refreshes, never a live lookup in waybar-net. -Shipped in the 2026-07-25 batch. The slow connectivity probe caches local addressing and a validated external IP with the network identity; the Waybar hot path only reads that valid cache. Tooltip order is Interface, internal/external/gateway IPs, connectivity detail, throughput, Signal, shortcut. -** DONE [#B] Dupre Kit merge — casting additions :feature:tooling:solo: -CLOSED: [2026-07-25 Sat 10:53] -Fold docs/prototypes/dupre-kit-additions.js back into the kit proper: detentFader (NEW — multi-detent slide attenuator with speedbump drag physics: magnet + escape hysteresis, parked tick glow) and the drumRoller redefinition (UPGRADE — 1..N channels and min/max range; stock hardcodes two drums and throws on one, defaults reproduce stock exactly) and the guardedToggle redefinition (UPGRADE — lever throws with rotateX so it flips toward the viewer instead of the stock 180° planar spin that sweeps sideways mid-transition; contract unchanged). Merge means: builders into widgets.js, the additions CSS into DUPRE_CSS, additions-scoped gradients into the shared defs plate, gallery cards for both in panel-widget-gallery.html, and POLICY entries. Origin: the desktop-settings casting sitting 2026-07-21 — Craig's direction is that components get finished by being needed ("the ones needed most will have had the most attention"), so more additions may accrue here before the merge; batch them. -Shipped in the 2026-07-25 batch. =widgets.js= now owns all three builders, shared gradients/CSS, contracts, and policy records; additions no longer redefines them when older casting pages load it. The gallery has a three-detent fader card and a three-channel 0–12 drum demonstration (112 cards total). Static ownership tests, JS syntax checks, and the complete headless interaction probe pass. -** DONE [#C] Maint live-refresh hairline replacement :feature:maint:solo: -CLOSED: [2026-07-25 Sat 10:53] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. -*** 2026-07-21 Tue @ 08:35:00 -0500 Decided (Craig): silent-until-stale age caption -Replace the draining 2px hairline with an age caption that shows ONLY when refresh is overdue (e.g. "3s", "8s" once past the expected window) and shows nothing while the board is healthy. This keeps the stale-vs-frozen signal — a frozen board surfaces a growing age number, a live one stays clean — while removing the constant motion the hairline created. Implementation (dotfiles, archsetup-owned): drop =gui._hair_tick= / the hairline draw, add an overdue-age caption driven off =viewmodel.refresh_fraction= (or the last-refresh timestamp) rendered only past the live window. Now unblocked; needs a live visual check on the panel after. -Shipped in the 2026-07-25 batch. The animated draw area and 150ms timer are gone; the memory section header stays silent through the healthy three-second window, then shows a once-per-second growing age caption. Pure boundary tests and the full maintenance suite pass. -** DONE [#D] Test-framework + prototype refactor cluster :refactor:solo: -CLOSED: [2026-07-25 Sat 10:53] -Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above. -scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7). -Completed test-first in the 2026-07-25 batch. QEMU and bare-metal runners share liveness/report helpers; maintenance transports share scenario validation/execution; token emitters share ordered section traversal; and the Emacs SVG gauge shares semicircle geometry and uses the public DOM append API. Every fast Python/ERT suite passes. -** DONE [#B] Two agent sessions sharing one git repo :chore:tooling: -CLOSED: [2026-07-26 Sun] -Craig approved the shared-rules-layer solution on 2026-07-26. - -Use one repository-scoped publish lock for every session and worktree sharing a clone. Derive the lock name from the real Git common-directory path; hold it across reconcile, stage, staged review, and commit; track the owning session and reviewed staged-tree fingerprint; refresh it after conversational waits; and repeat the staged review if ownership or the fingerprint changed. Ordinary working-tree edits remain concurrent. - -An approval waiver never waives the staged review, because that review is the gate that reads the actual hunks entering the commit. Rulesets owns the implementation in =commits.md=, =agent-lock=, and its Bats coverage; archsetup sent the approved implementation package through the rulesets inbox. -** DONE [#A] Reboot ratio to activate amdgpu.runpm=0 :bug:hyprland:ratio: -CLOSED: [2026-07-28 Tue] DEADLINE: <2026-07-28 Tue> -:PROPERTIES: -:CREATED: [2026-07-28 Tue] -:LAST_REVIEWED: 2026-07-28 -:END: -Craig's plan: close everything down, run topgrade, then reboot. Alarm set for 08:00 (=at= job 56, persistent desktop notify). - -=amdgpu.runpm=0= sits in =/etc/default/grub= and in the generated =/boot/grub/grub.cfg= (5 occurrences, so the reboot will actually apply it) but is absent from =/proc/cmdline=. The box has been up since 2026-07-22 21:10 and the fix landed 2026-07-24, so the running kernel predates it. The GPU is AMD Strix Halo (Radeon 8060S, =1002:1586=), exactly what the parameter targets: runtime power management invalidates the GPU resources hyprlock holds across a display power-cycle, so hyprlock exits without unlocking. - -That is the root cause under the 2026-07-27 lockdead screen. The screen-lock flock fix (dotfiles =ec18fd7=) stops one dead client from becoming a lockdead screen, but it treats the symptom -- this reboot treats the cause. - -Not :solo: — Craig closes his own session and runs topgrade first. - -Rebooted 2026-07-28 08:59. =amdgpu.runpm=0= confirmed present in =/proc/cmdline= afterward, so the parameter is finally live. - -Correction, 2026-07-29: the claim above and in the body that this is "the root cause under the 2026-07-27 lockdead screen" is wrong, and superseded. hyprlock was never crashing. Every logged exit is =rc=143=, SIGTERM, from =settings-watch= killing it by design. See =[#B] Night watch and the lock watchdog fight each other=. The reboot was still worth doing (the parameter is a genuine mitigation for a real AMD defect) but it did not fix this, and the lockdead screens continued after it. -** DONE [#B] Caffeine state is unreadable on both surfaces :bug:dotfiles:design:solo: -CLOSED: [2026-07-28 Tue] -:PROPERTIES: -:CREATED: [2026-07-28 Tue] -:LAST_REVIEWED: 2026-07-28 -:END: -Neither surface that reports caffeine tells the truth reliably, so there is no way to know at a glance whether the screen will lock. Found while investigating the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway. - -Defect 1 — the settings panel shows a frozen value. =gui.py= calls =_refresh_async()= once during window construction (line 454) and again only after the user's own actions (=_after_matrix=, line 663). The only two =GLib.timeout_add= calls are one-shots (the 2400ms toast hide and a 350ms fire), so nothing re-reads state on a timer. An open panel therefore displays the caffeine value from the moment it opened, forever. Any external flip -- the waybar click, Super+I, the =caffeine-toggle= script -- leaves it stale with no self-correction. The panel is the only surface in the repo carrying a caffeine control (=panel.py:25=); maint has none and does not embed these toggles, so this is the display Craig read. - -Defect 2 — there is no caffeine indicator on the bar at all. =custom/caffeine= appears in neither the stowed =hyprland/.config/waybar/config= nor the live generated =/run/user/1000/waybar/config=, and no =custom/caffeine= block is defined anywhere in the waybar config dir. The =waybar-caffeine= script exists, works, and has its own passing test suite, but nothing displays it. So the bar has never been a source of caffeine state, and the keybind and script have been signalling (=pkill -RTMIN+8 waybar=) a module that isn't there. - -(An earlier read of this task said the bar showed two near-identical glyphs. That was wrong: the module is absent, not merely unstyled. The script's class names are still backwards -- =active= when caffeine is OFF, =inhibited= when ON -- and neither class is styled, but both points are moot until the module is actually in the bar.) - -Grading: Major severity (the panel reports state wrongly while it is open, and the only other surface does not exist, so there is no reliable source for a setting Craig actively manages) x most-of-the-time (any external toggle while the panel is open; the bar never shows it) = P2 = [#B]. - -Fix all three. Wire =custom/caffeine= into the bar, rename its classes so they describe caffeine rather than idle, and style them from the existing palette. Give the panel's toggle row a re-read on a timer or on focus-in. Solo -- buildable and testable, and the direction is settled by the defects rather than a taste call, though the bar color is worth a glance from Craig once it renders. - -All three shipped as dotfiles =033076c=, pushed. =custom/caffeine= now sits in the bar between DND and settings on =interval: 2=; classes renamed =on=/=off= and both styled, caffeine-ON in the theme's gold =#dab53d=; the panel re-reads live state every 3s while visible. Verified live in the stowed config and the generated =/run/user/1000/waybar/config=. The full suite caught a theme-copy regression (=themes/dupre/waybar.css= out of sync with =waybar/style.css=) that the focused suites missed. -** DONE [#B] hyprlock still exits mid-lock; the watchdog relaunch is silent :bug:hyprland:dotfiles: -CLOSED: [2026-07-29 Wed] -:PROPERTIES: -:CREATED: [2026-07-28 Tue] -:LAST_REVIEWED: 2026-07-28 -:END: -Craig, 2026-07-28 ~15:00: saw the Hyprland lockdead/error text blurred *behind* a working lock screen; it vanished when he authenticated. - -That ordering is the diagnosis. hyprlock's blur samples what the compositor is currently rendering, so the compositor was already showing lockdead when the new hyprlock attached. Sequence: hyprlock exits non-zero (no coredump, so it exits rather than crashing), Hyprland renders lockdead because the client is gone while the session stays locked, =screen-lock='s watchdog relaunches within =LOCK_RELAUNCH_DELAY= (0.5s), and the new client draws over the lockdead frame and blurs it. - -*The recovery worked.* On 2026-07-27 this same hyprlock exit produced two contending clients and a session recoverable only from another console. It now self-heals in half a second, and the residue is cosmetic. Both the flock guard (dotfiles =ec18fd7=) and the watchdog did their jobs — verified in this session's compositor log, where all four lock events created exactly one =sessionLock= and one =sessionLockSurface= each, against two of each on 2026-07-27. - -Two things remain. - -*Why hyprlock exits.* The wrapper's header blames GPU-resource invalidation across a display power-cycle (hyprlock#953), which =amdgpu.runpm=0= targets — and that parameter is live as of the 2026-07-28 08:59 reboot, confirmed in =/proc/cmdline=. There is also no DPMS idle rule any more (=e900903=), so idling never power-cycles the display. Yet hyprlock still exited. Strongest untested candidate: a screen recording (=wf-recorder= into =~/sync/recordings/2026-07-28-12-53-57.mkv=, running 12:53 until Craig killed it) held screencopy sessions on DP-4 across the lock. The compositor log carries 2454 screenshare sessions and a =CScreencopyProtocol= bind in the window between the last two locks. A screencopy client churning dmabufs alongside hyprlock's own is a plausible way to invalidate them, and it was the one large new variable that day. - -*The relaunch is silent.* The watchdog loop re-runs hyprlock and logs nothing, so there is no record of how often this fires, when, or with what exit code — which is exactly why the frequency couldn't be established from the logs. Log the exit code and a timestamp on each relaunch. - -Grading: Major severity (the lock client dies mid-lock, and the pre-fix version of this wedged a session unrecoverably) x most users frequently (twice in three days, and this is a single-user machine, so every occurrence lands on the only user) = P2 = [#B]. Downgraded from the 2026-07-27 [#A] because the wedge is fixed and the failure now self-heals. - -An earlier draft of this grading said "some users sometimes", which the matrix maps to P3 = [#C], not the [#B] written beside it. The frequency row was the wrong input rather than the letter: on a one-user machine a fault hitting twice in three days is frequent, not occasional. Corrected the input per the rule that a disputed grade is fixed at its inputs. - -Solo for the instrumentation half only: adding the relaunch logging is buildable, testable against the existing =tests/screen-lock= suite, and needs no decision. Diagnosing the exit is not solo — it needs a reproduction, and the likely trigger is Craig recording his screen. - -Next step when picked up: land the relaunch logging first so the next occurrence produces evidence, then try to reproduce by locking with =wf-recorder= running. - -Superseded 2026-07-29 by =[#A] Night watch and the lock watchdog fight each other=. The logging landed (dotfiles =5bbe2c3=) and answered it within hours: three =rc=143= entries, SIGTERM, from =settings-watch= killing hyprlock by design. Nothing was crashing, so both the AMD-iGPU and the screen-recorder hypotheses in this task are wrong. Kept closed rather than deleted because the reasoning that led here is worth the record. -** DONE [#A] Idle commits silently drop the screen-lock wrapper :bug:hyprland:dotfiles:security: -CLOSED: [2026-08-04 Tue] DEADLINE: <2026-07-29 Wed> +* Archsetup Resolved +** DONE [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware: +CLOSED: [2026-08-26 Wed] DEADLINE: <2026-08-26 Wed> :PROPERTIES: -:CREATED: [2026-07-29 Wed] -:LAST_REVIEWED: 2026-07-29 +:CREATED: [2026-08-13 Thu] +:LAST_REVIEWED: 2026-08-13 :END: -Caught live 2026-07-29 05:30, seconds after it happened, while verifying that Craig's watch-stage change had landed. - -=idle.py= renders the *whole* hypridle.conf, including a hardcoded =GENERAL= block. That block said =lock_cmd = pidof hyprlock || hyprlock=. The live config said =|| screen-lock=. So every idle-stage commit through the panel rewrote =lock_cmd= and dropped the wrapper out of the chain. +Machine off, lift the input cover (Framework QR-guided procedure, 5 +fasteners), reseat its ribbon connector to the mainboard — disturbed in the +2026-08-13 board swap. Root cause of every "mystery reboot" that day: +chassis flex (flash-drive touch, ethernet bump, lid partially lowered) +fired phantom power-button presses — journalctl -b -1 showed "Power key +pressed short." → orderly logind poweroff, then the glitching button +powered it back on. While in there, reseat the USB expansion cards too — +the flaky slot (two hard resets, one no-enumeration) is likely the same +flex problem. +THIRD SYMPTOM (2026-08-13 evening): touchpad delivers ZERO input events — +15s synchronized libinput debug-events capture while swiping caught +nothing, though i2c enumeration and a driver rebind handshake are clean. +Signature of a dead interrupt line on the same ribbon. Keyboard + power +LED lines work; BT mouse is the interim pointer. +ESCALATED 2026-08-13 21:00: a fourth event killed the machine THROUGH the +shield. Previous boot's journal ends mid-line (tailscaled chatter) with no +shutdown sequence at all — a hard power cut, not logind acting. So the +glitch now reaches the EC/hardware power path, which no software setting +can intercept. The reseat is the only fix, and this is a +lose-work-without-warning failure mode, not an inconvenience. +Interim shield (already live): /etc/systemd/logind.conf.d/powerkey.conf +sets HandlePowerKey=ignore — phantom presses log but do nothing; EC-level +10s hold still force-cuts. Consider keeping it even after the repair. +Verify after reseat: flex the chassis edges + partially lower the lid, then +grep the journal for new "Power key pressed" lines — zero means fixed. +Must be done before the Sunday flight — a phantom press mid-travel with the +shield on is survivable, but the connector should not be trusted at 30,000 +feet on the loose setting. -The wrapper is not incidental. It carries the flock duplicate guard (the fix for the 2026-07-27 unrecoverable wedge), the crash-relaunch watchdog, and the relaunch log. Parking one stage removed all three in a single write, and nothing said so. +*** 2026-08-19 Wed @ 14:40:00 -0700 Retracted: the RTC reset is not this task's, and I should not have filed it here +I attributed the 2026-08-19 network outage to this ribbon earlier today. Craig +pushed back — he reseated it before the trip to get the touchpad working — and +he is right. The evidence does not support the attribution and some of it points +the other way. -=tests/settings/test_settings.py:562= asserted the bare =|| hyprlock= form, so the suite *enforced* the regression. That is why 3845 tests stayed green through a day of work on exactly this subsystem. A test can pin the bug as readily as the fix. +What actually holds. Boot -3 ended at 01:33:18 with no shutdown sequence: no +power-off target, no unmounting. The next boot's kernel line reads =rtc_cmos +00:01: setting system clock to 2025-01-01T00:00:16 UTC=, a firmware default, so +the RTC was reset rather than drifted. No firmware update was applied +(=fwupdmgr get-history= is empty) and the battery is fine. -The false-negative this sets up is worth naming: with the wrapper gone the relaunch log stops receiving entries, and an empty log reads as "the problem is fixed" when it means "the instrument was removed". The =screen-lock= header already warns that an empty file is not proof; this is the mechanism that would have produced one. +What refutes the ribbon. This boot logged *zero* =Power key pressed= events, and +so did the four boots before it. The phantom-press symptom had genuinely stopped +after 08-15, exactly as the 08-16 session recorded. The earlier events logged a +power-key press and an orderly poweroff; this logged neither, which makes it a +different signature, not a worse version of the same one. -Fixed by dotfiles =ab059fb= (2026-07-29 05:59). Verified 2026-08-04 against the tree rather than the commit message: =idle.py:47= renders =lock_cmd = pidof hyprlock || screen-lock || hyprlock=, the live =hypridle.conf= matches, and =test_settings.py= now asserts the wrapper is in the chain plus a second test for the bare-hyprlock fallback. The test that used to pin the bug now pins the fix. +What I got wrong methodologically: I anchored on the most salient open hardware +task and read association as evidence. I even wrote "I can't prove it is the +same connector" and then filed it here anyway, which is the tell. -The evidence that matters is the one this task named: =~/.local/var/log/screen-lock.log= is *receiving entries*, so the instrument is present. An empty log was the false negative to fear, and it did not happen. +Two things I checked and can rule out. There were no OOM kills — the 3,433 +matching lines are a systemd unit named "Periodically re-score Claude Code +processes for the OOM-killer" firing on a timer, not memory pressure, and there +is not a single "Killed process" line. Thermal is clean; the only mentions are +boot-time zone registration at 34C and 45C. -FIXED here, TDD, in the working tree pending commit: -- =idle.py= =GENERAL= now names =screen-lock=, with a comment saying why the line is load-bearing. -- The test now pins the wrapper form. Red first against the old template. -- Live config rewritten through the panel's own path and hypridle restarted; =lock_cmd= confirmed back to =screen-lock=, one hypridle running. +One real thing the same window did surface, tracked separately: a python3 crash +loop, 251 core dumps in the final ten minutes, SIGABRT with =XFreeThreads= and +=PyEval_RestoreThread= in the trace. It does not explain the RTC, because +software cannot clear it, but it is its own problem. -Grading: Critical severity (=write_conf= truncates, so any hypridle key the renderer does not model is silently deleted rather than preserved — that is configuration data loss, and the =lock_cmd= case proved it happens in the field) x some users sometimes (only when an idle stage is committed, which is rare) = P2 = [#B]. +The open question that would settle the RTC is for Craig, not the journal: a +long power-button hold on a Framework triggers an EC-level reset that clears the +RTC, which fits a wedged machine being forced off. A 4-second hold would not. -An earlier draft graded this [#A] on a "security carve-out". That was wrong: disarming the guard is an availability problem, not a leak, and the carve-out is for privacy, security, compliance and safety. The severity band is what carries the weight here, and silent deletion of configuration is the =Critical= band's data-loss case. +*** 2026-08-17 Mon @ 19:57:42 -0700 Not done, and the two symptoms now disagree +The reseat did not happen before the flight, and velox is travelling. The +deadline blew past on 08-14. -Two further fixes came out of an independent review of the first one: +The two symptoms have separated, which is worth recording because it changes +what the evidence proves. The phantom presses have stopped: fifteen "Power key +pressed" entries between 08-14 04:29 and 08-15 20:04, then nothing at all +across five boots including today's. The touchpad has not — there is still no +touchpad node under =/dev/input/by-path/=, which is the same dead interrupt +line the body describes. -- *Fail-open restored.* =pidof hyprlock || screen-lock= made the wrapper the end of the chain, and =screen-lock= is a stow symlink in =~/.local/bin=, not a system binary. An unstowed tree, or a hypridle started without =~/.local/bin= on PATH, resolves it to 127 — so the screen would never lock *at all*. That is worse than the duplicate client the wrapper prevents. The chain now ends =|| hyprlock=, matching the wrapper's own fail-open discipline. -- *The file now says it is generated.* Three comment lines at the top of the rendered output name the renderer and warn that edits are overwritten. The absence of that header is how the divergence survived unnoticed. +So the quiet power button is not evidence the connector reseated itself. The +interrupt line is the symptom that cannot be masked in software, and it is +still dead, so the ribbon is still unseated. The most likely reason the +presses stopped is that the machine has been sitting on hotel surfaces instead +of being carried and flexed. -Still open, and why this stays a task rather than closing with the fixes: the header warns, but nothing *prevents* the next divergence, and the exposure is wider than =lock_cmd= alone. The review enumerated it: +The interim shield is still live (=HandlePowerKey=ignore=), and the escalation +note stands: an EC-level glitch cuts power below systemd regardless of it. +*** 2026-08-15 Sat @ 23:05:00 -0500 The reseat did happen, and the touchpad came back — this contradicts the 08-17 read +Recording this because a parallel session concluded on 08-17 that the reseat had +not happened and the touchpad was still dead. Both halves were done and verified +that night, so the two accounts disagree and the disagreement should be visible +rather than silently resolved by whichever session committed last. + +What was done: the input-cover ribbon was reseated first, which fixed the +phantom power button — the 22:09 boot logged zero =Power key pressed= lines +after Craig flexed the chassis, against nine on the boot before. The touchpad +did not change, because the input-cover ribbon is not its connector. The 4-pin +connector beside the printed =TOUCHPAD= label is silkscreened =PIN 1-2 GND / +PIN 3-4 VCC= — pure power, so it cannot carry i2c or an interrupt. Reseating the +ribbon that actually crosses to the mainboard fixed it. + +Measured, not assumed: the touchpad interrupt (=amd_gpio= pin 8) went from 0 +counts across all 24 CPUs to 1795, and =i2c_hid_acpi ... did not ack reset +within 1000 ms= disappeared from the boot log. Craig confirmed the pointer moved. + +*Why the 08-17 probe likely misread it:* it checked for a node under +=/dev/input/by-path/=. i2c-HID touchpads frequently get no =by-path= symlink +even when fully working, so its absence is not evidence of a dead interrupt +line. The falsifiable check is the interrupt count in =/proc/interrupts= while +the pad is being touched, or the reset message in =dmesg=. + +*Left open rather than closed* — velox was refusing ssh at merge time on 08-20, +so the current state could not be re-verified, and a later regression cannot be +ruled out. One second of Craig's time settles it: move the pointer. If it works, +close this; if it does not, the interrupt line went back down and that is new +information. + +*** 2026-08-26 Wed @ 22:30:46 -0600 Closed: the reseat was done on 08-15 and the task was never marked +I reseated the ribbon on 2026-08-15 and never closed this. The 08-15 entry +above already records the verification: zero =Power key pressed= lines on the +22:09 boot after flexing the chassis, the touchpad interrupt count back up +once the right connector was reseated. This boot shows zero presses as well. +The interim shield (=HandlePowerKey=ignore= in +=/etc/systemd/logind.conf.d/powerkey.conf=) is still live; I'm leaving it in +place, since a phantom press with it on costs nothing and without it costs +the session. +** DONE [#B] Velox touchpad interrupt line is dead — needs a part or a BIOS fix :bug:velox:hardware: +CLOSED: [2026-08-15 Sat] +:PROPERTIES: +:CREATED: [2026-08-15 Sat] +:LAST_REVIEWED: 2026-08-15 +:END: +*Fixed 2026-08-15 23:05 by reseating the correct connector* — a seating fault +all along, no part needed. Verified at the kernel level on the 23:05 boot: the +=did not ack reset within 1000 ms= message is gone (clean handshake), and the +interrupt count went 0 → 1795. Power-key events also zero, so both faults from +the mainboard swap are closed. + +What made this take three attempts is worth keeping: two of the connectors on +that board were decoys. The input-cover ribbon looked like the obvious suspect +and fixing it *did* resolve the power button, which made it look like the whole +answer. Then the 4-pin connector next to the printed =TOUCHPAD= label looked +like the touchpad's own — and its cable is silkscreened =PIN 1-2 - GND / +PIN 3-4 - VCC=, four contacts of pure power, incapable of carrying i2c or an +interrupt. Reading that silkscreen off the photo is what ruled it out and sent +the search to the ribbon that actually crosses to the mainboard. + +The ordered touchpad becomes a spare, which is what Craig wanted from it anyway. +The diagnostic path below is left intact — it is the reusable part: =dmesg= +for the i2c-HID reset message and the interrupt count in =/proc/interrupts= +together separate "device absent" from "device present but its interrupt line is +open", and a live USB separates hardware from software in two minutes. +Split from the ribbon-reseat task 2026-08-15 once the reseat fixed the power +button and left this untouched — they are two faults, not one. + +*Diagnosed to the interrupt line specifically, with software eliminated.* +- The i2c *data* path works. =i2c_hid_acpi= read the HID descriptor, returned + the right product ID (=093A:0274=), =hid-multitouch= bound, and input6/7/8/9 + were created. A descriptor read is a real bus transaction, so the device is + electrically present and answering. +- The *interrupt* path never fires. IRQ 81, =amd_gpio= hwirq 8, level-triggered, + =actions=PIXA3854:00= — the handler is correctly registered on the pin the + firmware names. Count is 0 across all 24 CPUs, including during active + swiping. +- =dmesg=: =i2c_hid_acpi i2c-PIXA3854:00: device did not ack reset within 1000 ms=. + The i2c-HID reset handshake is acknowledged *by the device asserting the + interrupt*, so the first operation needing that line already failed at boot, + before anything touched the pad. That is why the fault reproduces on any boot + in ten seconds. +- *Software ruled out by live USB.* Same "did not ack reset" message and no + pointer movement under Ubuntu's kernel (2026-08-15). Not a driver, not + libinput, not Hyprland, not this install. + +Three candidates remain, all needing a part or firmware: +1. Open conductor on the touchpad's own cable or a bad contact at either end. + Framework sells "Touchpad Cable" as a discrete spare, so it is separately + replaceable — and the input-cover ribbon reseat would not have touched it. +2. The touchpad module's interrupt output is dead while its i2c slave still + answers. Indistinguishable from 1 without swapping parts. +3. Firmware naming the wrong GPIO. The DSDT says =amd_gpio= pin 8; if this + board revision routes the interrupt elsewhere, the kernel watches a pin that + never toggles. Plausible because the mainboard is days old to this machine + and its firmware already needed the PSR workaround. BIOS is 03.05 + (2025-10-30); kernel 6.18.44-1-lts. + +*The connector that was reseated is NOT the touchpad's — confirmed from the +board photo.* Craig reseated the 4-pin connector near the printed word +=TOUCHPAD=. Its cable is silkscreened =PIN 1-2 - GND / PIN 3-4 - VCC= — four +contacts, all of them power. No clock, no data, no interrupt; almost certainly +the keyboard backlight feed. An i2c-HID touchpad cannot run through it, so that +reseat could never have fixed this, and *the free retry remains untried*. +Photo: [[file:working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg][working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg]]. + +Visible on that board: the controller IC marked =PCT3854= (matching the kernel's +=PIXA3854=), a larger =CON3= carrying a blue-backed ribbon with "26" marked +beside it, a white ZIF past the Framework QR label, and a further connector at +the board's end. The one that matters is whichever ribbon physically *leaves the +input cover and reaches the mainboard* — that is the touchpad cable, and its far +end is the press-fit connector at the board. Reseat both ends of that one before +fitting any new part. + +*BIOS 04.02 exists but does not look relevant.* Checked 2026-08-15 with velox +on AC at 90%: fwupd offers 0.0.3.5 → 0.0.4.2. Read the changelog — the only +touchpad line is haptic-touchpad support for the Laptop 13 *Pro* chassis, and +this machine has a conventional PixArt =PIXA3854=. The rest is BIOS Setup +layout, option naming, TPM behavior, iGPU defaults, PMF slider. Nothing about +GPIO routing or interrupt configuration. So candidate 3's cheap test is weaker +than it looked when it was filed sight-unseen; still worth doing (unlisted +fixes happen, and ACPI tables change), just no longer the front-runner. +Deliberately deferred past the flight — a cleared NVRAM is the failure that +started this whole rebuild. Boot-entry recovery reference captured at +[[file:working/velox-reinstall/velox-uefi-boot-entry-reference.org][working/velox-reinstall/velox-uefi-boot-entry-reference.org]]. + +Order of attack on return, cheapest first: reseat the touchpad's *own* press +connector at the mainboard (free, untried) → BIOS 04.02 → fit the replacement +touchpad. Craig's call 2026-08-15: order the parts now anyway, since they are +worth holding as spares regardless of which candidate wins. + +*What to order.* The replacement *Touchpad* ships with the Touchpad Cable +pre-installed, so that single part covers candidates 1 and 2 together — no need +to buy both to cover both. A bare Touchpad Cable is worth adding only as a cheap +spare. The *Input Cover* is a different and more expensive part, and nothing +points at it: the keyboard works, so the input-cover ribbon is carrying signal. +Framework's marketplace renders its catalogue in JavaScript, so prices could not +be read programmatically — search "Touchpad" under Laptop 13 parts. + +*Also worth a Framework support ticket* — the touchpad died coincident with +their mainboard swap, which may put it inside whatever recourse that carries. + +Grading: Major severity (a laptop's built-in pointer is entirely dead — the +counter-argument is that an external mouse is a complete workaround, which +would make it Minor; I took Major because losing the integrated pointer degrades +the machine's portability, which is the whole point of the laptop) x every user, +every time = P1 = [#A]. Filed [#B] rather than [#A] only because an [#A] must +carry a date and Craig's return date isn't known yet — date it and raise it to +[#A] when it is. + +Workaround in the meantime: Bluetooth mouse, already in use. -- =before_sleep_cmd= and =after_sleep_cmd= sit in the same hardcoded block, at identical risk. -- Every stage command is hardcoded in =_stage_commands= (brightness level, lock, watch, dpms, suspend), same one-way overwrite. -- =write_conf= *truncates* rather than merges, so any hypridle key the renderer does not know about (=ignore_dbus_inhibit=, =ignore_systemd_inhibit=, =inhibit_sleep=, =on-lock=, =on-unlock=) is deleted rather than preserved. That is the largest hole: a key nobody has added yet would vanish the first time a stage is parked. -Options: have the renderer preserve the existing general block and unknown keys instead of emitting its own, or accept the template as the single source and move every hypridle setting into the panel. A design call for Craig, and the truncation half is the part that will bite next. ** DONE [#C] hypridle.conf is generated per-machine but tracked :refactor:dotfiles: CLOSED: [2026-08-14 Fri] :PROPERTIES: @@ -4068,159 +3615,6 @@ ratio and absent on velox; full evidence and rationale in Scope: installer step + rule file + tests per existing shapes, and apply both live to velox over tailscale (daily-driver sync — neither exists there today). -** DONE [#A] Comet KVM setup for truenas :feature:infra:truenas: -CLOSED: [2026-08-08 Sat] -:PROPERTIES: -:CREATED: [2026-07-27 Mon] -:LAST_REVIEWED: 2026-07-27 -:END: -Resolved: Craig wired up and configured the Comet himself, confirmed working -2026-08-08. The ATX power-board follow-up (hard power-cycle for a truly wedged -box) remains unfiled — raise it if the next outage shows the KVM alone isn't -enough. -Wire up the GL.iNet Comet (GL-RM1) IP KVM against truenas. It was bought 2026-01-14 for exactly this job and its KB node still reads "Arrived, not yet set up." - -Why now: truenas went dark 2026-07-24 and stayed unreachable. Diagnosis from ratio on 2026-07-27 — no tailnet contact for 3 days, 100% packet loss on 192.168.86.5, ARP entry FAILED (nothing answers ARP for the address, so the NIC is down at layer 2), every service port closed, while the gateway and a dozen other LAN hosts stayed reachable. Wake-on-LAN to 70:85:c2:db:9d:94 drew no response. With no console and no out-of-band power control there was no remote remedy at all, so recovery needed hands on the box. The Comet closes exactly that gap: BIOS/UEFI console, Wake-on-LAN, and browser access over its native Tailscale integration. - -Not :solo: — the physical cabling is Craig's, and the Tailscale enrollment needs his account. - -Steps, from the KB node ([[id:67bc5994-a763-48e2-926f-4ac0d1bad3db][GL.iNet Comet (GL-RM1) - KVM]]): -1. HDMI from truenas video out to the Comet's HD IN. -2. USB-A-to-USB-C from the Comet to a truenas USB port (keyboard/mouse emulation). -3. Ethernet to the network. -4. Power via USB-C (5V/2A). -5. Reach the web interface and enroll it in Tailscale, so it's usable when the LAN side of truenas is the thing that's broken. - -Then verify while truenas is healthy, rather than discovering the gaps during the next outage: confirm the console shows POST and the BIOS, that keyboard input reaches the box, and that Wake-on-LAN from the Comet actually powers it on. Enable WOL in the truenas BIOS if that last check fails — this outage never established whether it was on. - -Worth considering as a follow-up: the ATX power board accessory gives hard power-cycle control for a truly wedged box, which the KVM alone can't do. -** DONE [#A] Review post-archsetup laptop setup steps (velox 2026-04-10) -CLOSED: [2026-08-08 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-08-08 -:END: -Closed at the 2026-08-08 session: every open item got its automate-vs-document -call and the work landed the same night (tests green, committed). Residual: -velox itself still needs the new tlp.d radio line and a dotfiles pull — folded -into the [#A] sleep/suspend task, which works the same files on velox anyway. -Items discovered during velox setup that needed manual intervention after archsetup. -Decide which should be automated in archsetup vs documented as post-install steps. - -*** 2026-08-08 Sat @ 04:43:42 -0500 Automated radio enable via TLP (rfkill boot soft-block) -Root cause sharpened during triage: archsetup masks systemd-rfkill on laptops -(it fights TLP), so nothing restored radio state at boot — the "unblock once -should stick" premise was wrong under the mask. Fix in the TLP custom conf: -=DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"=, the TLP-native mechanism. -configure_tlp_power parametrized for tests; covered by -tests/installer-steps/test_configure_tlp_power.py. - -*** 2026-07-04 Sat @ 11:48:24 -0500 Automated /efi restrictive mount permissions in fstab generation -archsetup:2827-2836 now rewrites the /efi fstab line to =fmask=0177,dmask=0077= (idempotent), so fresh installs no longer land the world-accessible =fmask=0022,dmask=0022= default. Confirmed via the 2026-07-04 task audit. (Original velox note: default vfat mount had =fmask=0022,dmask=0022=, hand-fixed to restrictive; bootctl warned about a world-accessible random-seed file.) - -*** 2026-08-08 Sat @ 04:43:42 -0500 Automated tmp.mount mask for ZFS /tmp -New mask_tmp_mount_for_zfs, called from configure_snapshots' ZFS branch: -masks tmp.mount only when the pool actually carries a dataset mounted at -/tmp (exact match), silent no-op without zfs or without the dataset. Covered -by tests/installer-steps/test_mask_tmp_mount_for_zfs.py; the orchestrator -dispatch pin updated. - -*** 2026-08-08 Sat @ 04:43:42 -0500 Automated CPU microcode install by vendor -New install_cpu_microcode, first in boot_ux so grub-mkconfig and mkinitcpio's -microcode hook both see the installed /boot/<vendor>-ucode.img: vendor_id from -/proc/cpuinfo → intel-ucode / amd-ucode, error_warn on unknown vendor. -Covered by tests/installer-steps/test_install_cpu_microcode.py; boot_ux -sequence pin updated. - -*** 2026-07-04 Sat @ 11:48:24 -0500 Automated syncthing user-service enable in archsetup -archsetup:2263-2271 now installs syncthing and enables the user service (via symlink), so fresh installs no longer leave it installed-but-disabled. Confirmed via the 2026-07-04 task audit. (Original velox note: package installed but service not enabled; hand-fixed with =systemctl enable --now syncthing@cjennings=.) - -*** 2026-08-08 Sat @ 04:43:42 -0500 Closed the awww-daemon crash watch — no recurrence -The April boot crash never recurred across four months of daily use on both -machines (and the wallpaper stack has since been reworked). Reopen as its own -bug with fresh evidence if it ever comes back. - -*** 2026-08-08 Sat @ 04:43:42 -0500 Automated touchpad device detection in the pointer scripts -The scripts were already in stowed dotfiles with binds — the open half was the -hardcoded Framework device name. Both touchpad-auto and toggle-touchpad now -auto-detect the touchpad (first pointer named *touchpad*, pixa fallback) and -derive the internal-pointer exclusion set from the detected name, so they -agree on any machine. Test seams added (--detect / --has-external-mouse); -tests/touchpad-auto/ new, toggle-touchpad suite still green. Dotfiles commit; -velox picks it up on its next pull. - -*** 2026-08-08 Sat @ 04:43:42 -0500 Documented bluetooth pairing in the post-install checklist -Inherently interactive, so it can't ride the installer. Documented in the new -[[file:docs/post-install-checklist.org][docs/post-install-checklist.org]] along -with the Proton Bridge steps — the standing home for manual post-install work. -Consider: document as post-install step. No automation possible. - -*** 2026-05-26 Tue @ 13:32:31 -0500 pocketbook install concern moot — pulled from publication, folded in-tree -Resolved by removing pocketbook from archsetup's provisioning entirely. It's nowhere near ready, so the github mirror + cjennings.net repo were deleted and the project was folded into the archsetup tree at =pocketbook/=. Dropped the =gtk4-layer-shell= dep + =pip_install= from =archsetup= and the clone from =scripts/post-install.sh=. No fresh install pulls pocketbook now, so "not installed on velox" no longer applies. Re-wiring the install is tracked in the new pocketbook development backlog. - -*** TODO Review: Tailscale needs login after install -~tailscaled~ service was enabled but needed ~tailscale up~ for interactive auth. -Old machine entry needed cleanup in admin console. -Consider: document as post-install step. - -*** TODO Review: docs/ directories need manual sync from existing machine -docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ratio. -Same for ~/.emacs.d/docs/. Not in git, so not available after clone. -Consider: document as post-install step or create a sync script. -** DONE [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar: -CLOSED: [2026-08-08 Sat] -Closed at the 2026-08-08 task review: Craig confirms the separator work landed -a while back and the bar reads correctly now. -Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules. -Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C]. -Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule). -** CANCELLED [#C] Add a whole-display dim mode :feature:hyprland: -CLOSED: [2026-08-08 Sat] -Killed at the 2026-08-08 task review: the July auto-dim work covers the actual -need; no separate dim-everything mode wanted. -Extend auto-dim with an explicit “dim everything” setting for bright -non-dark-mode contexts, with a security/usability review of its scope. -** DONE [#C] Fix install errors surfaced by the 2026-05-11 VM test run -CLOSED: [2026-08-08 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -Closed at the 2026-08-08 task review: every archsetup-attributable error was -fixed and verified (fontconfig, dconf x2, emacs-stow, AUR exit-0 logging at -the root); the residual four reproduce unchanged and are diagnosed -environment/non-critical, with two 2026-06-28 full runs attributing zero -issues to archsetup. Residual thread: confirm the firewall nf_tables pair on -bare metal at the next real install — no container task needed to carry it. -*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set -Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread. - -*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set -The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized. -Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6. -- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=). -- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install). -- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above). -- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug. -- verifying firewall is active — exit 1 (follow-on from the firewall-enable error). -- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical. -- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?). -Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM). - -*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root -Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task. - -*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern -2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow. - -*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end -=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path. -** CANCELLED [#C] Review current tool pain points annually -CLOSED: [2026-08-08 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -Killed at the 2026-08-08 task review: an undated annual intention that never -fired — pain points get surfaced organically as they bite. -Once-yearly systematic inventory of known deficiencies and friction points in current toolset ** CANCELLED [#B] agent-text relay reports success for a message that went nowhere :bug: CLOSED: [2026-08-19 Wed] :PROPERTIES: @@ -4422,3 +3816,453 @@ real hang in the script" and it was neither. Fixed by pinning =stdin=subprocess.DEVNULL= in =run_script=. Verified both ways: the previously-failing open-pipe case and the redirected case both pass in 0.08s, and a full =make test-unit= under a live pipe is clean across 50 suites. +** DONE [#A] powerprofilesctl crashes on a loop since ppd was masked :bug:velox:dotfiles: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:CREATED: [2026-08-17 Mon] +:LAST_REVIEWED: 2026-08-17 +:END: +Something polls power state every 10-30 seconds, and each poll runs +=powerprofilesctl get=, which SIGABRTs. 47 coredumps on velox on 2026-08-17 +alone, the earliest at 08:34, four in one minute while I was watching. + +Cause is the 2026-08-16 fix that masked =power-profiles-daemon= so TLP +survives on laptops. That fix is right and stays. What it did not account for +is the settings module's power backing +(=~/.dotfiles/settings/src/settings/power.py=), which shells out to +=powerprofilesctl=. Against a masked unit the D-Bus activation fails with +=NameHasNoOwner ... unit is masked=, and the caller aborts rather than +degrading. + +Run by hand the same command exits 0 and prints the error, so the abort is +context-dependent and the caller needs finding before the fix is written. +Ratio does not mask ppd, which is why this is velox-only and why it appeared +the day after the masking. + +Costs: journal spam, coredump disk churn, and repeated failed D-Bus +activations on a travelling laptop's battery. It is also the leading suspect +for the wedged user manager filed below. + +Fix shape: =power.py= should treat a masked or unavailable ppd as a +first-class "no profile control here" state rather than an error path, and +the poller should stop retrying a unit it has been told is masked. The +machine-level half is already correct. + +Grading: Major severity (a crash loop burning battery and filling the +journal, silently) x every user every time on any laptop with the TLP fix +applied = P1 = [#A]. + +*** 2026-08-17 Mon @ 19:57:42 -0700 The loop stopped at the reboot; the defect did not +velox rebooted at 16:04 and there have been zero coredumps since, against 47 +in the twelve hours before it. So the loop is not currently burning anything. + +That is not a fix, and the distinction matters for whoever picks this up. +=powerprofilesctl get= still fails exactly as recorded — =NameHasNoOwner ... +unit is masked= — so every precondition for the loop is intact and it returns +whenever the caller next polls. What the reboot cleared is the caller's state, +not the bug. + +Narrowed the search the body asks for: =power.py= is the *only* file in +dotfiles that shells out to =powerprofilesctl= (=SETTINGS_POWERPROFILESCTL=, +line 14), so the caller is inside the settings module rather than waybar or a +timer. Worth knowing that the coredumps are =powerprofilesctl= itself aborting +— it is a python script, which is why they log as =/usr/bin/python3.14= +SIGABRT rather than under its own name. + +Grade unchanged. The matrix inputs did not move: the severity is what happens +while the machine is in that state, and the frequency row is every laptop +carrying the TLP fix. A quiet interval since a reboot is not a frequency +change. + +Fixed in dotfiles =e89d9db=. The caller was =waybar.py=, using =panel.read_state()= (the full snapshot of every control) to read one boolean, four bar modules deep on a 2-second interval. Two fixes, each needed alone: =panel.read_control()= reads a single control's backing, and =power.masked()= checks the mask symlink before shelling out. Verified with a logging stub: full snapshot unmasked calls powerprofilesctl once, masked calls it zero, and a waybar poll calls it zero even unmasked. +** DONE [#A] The installer clones my two working repos shallow and read-only :bug:velox: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:CREATED: [2026-08-17 Mon] +:LAST_REVIEWED: 2026-08-17 +:END: +=archsetup:1432= clones the user's archsetup repo and =archsetup:1445= clones +dotfiles, both with =--depth 1=. Those are not build directories. They are the +two repos I actively develop in, and on velox they came back from the +2026-08-13 rebuild with 7 commits of history each instead of 851. + +Found 2026-08-17, and found the worst way: I ran the credential-file history +check that the GitHub-release task asks for, and it reported all five files +absent from history with a clean exit. The real answer is that this clone +cannot see the history those files live in. A shallow clone does not error on +=git log -- <path>=, it answers "no commits" — so a security question came back +falsely clean, and nothing about the output said otherwise. + +Everything else it breaks is quieter: =git log=, =blame=, =bisect=, and any +archaeology past the boundary. The tree looks completely normal, which is why +this survived four days on the machine. + +The right shape is already in the codebase. =scripts/post-install.sh:42-51= +takes depth as a per-repo argument and defaults to a full clone, so wallpaper +gets =--depth 1= and org does not. The AUR build clones (=archsetup:855=, +=:1673=, =:1677=) are correctly shallow and stay that way. Only the two +user-repo sites change. + +*Second defect, same two lines, found 2026-08-17 while pushing:* the dotfiles +clone could not push at all. =archsetup:245= defaults =dotfiles_repo= to +=https://git.cjennings.net/dotfiles.git=, the public read-only endpoint, so +=git push= returned 403. Ratio uses =git@cjennings.net:dotfiles.git= and +archsetup's own clone uses the matching ssh form, so velox was the odd one out +purely because it was the machine rebuilt by the installer. Repointed velox's +remote and pushed. + +That half needs a decision rather than a fix, which is why this task is no +longer =:solo:=. The https default is *correct for a stranger* installing +archsetup, who has no ssh key on the server, and this repo is being prepared +for public release. It is wrong for my own machines, which need to push. The +override already exists (=DOTFILES_REPO=, documented in +=archsetup.conf.example=), so the question is only where my personal value +lives: a config the personal ISO bakes in, a post-install step, or a detection +that prefers ssh when a key is present. Craig's call. + +*Decided 2026-08-19: the ISO bakes the value, and a check nets the rest.* +=archsetup:240= has the identical default for =archsetup_repo=, so this was +always two repos rather than one. I ruled out detection — archsetup never +restores =~/.ssh=, so key-presence at clone time depends on ordering it +doesn't control, and "any key means ssh" would break a stranger who has an +unrelated one. I ruled out a bare post-install step for the reason this whole +class of bug exists: manual steps don't get run, which is why this sat four +days. So the personal ISO carries =ARCHSETUP_REPO= / =DOTFILES_REPO= in the +ssh form (noted on the secrets/ISO task), and =post-rebuild-check= check 8 +flags any working repo still on the read-only endpoint — covering curl|bash +and stock-ISO installs, which the ISO value cannot reach. + +Repair on a machine already built: =git fetch --unshallow= in each repo, and +=git remote set-url origin git@cjennings.net:<repo>.git= for dotfiles. + +Grading: Major severity (two working repos silently missing their history on +the machine I develop on, and it returns confidently wrong answers to history +questions rather than failing) x every user every time (every fresh install, +both daily drivers) = P1 = [#A]. + +Not :solo:. The depth half is (two lines plus tests in the existing +=tests/installer-steps/= shape, verifiable by asserting the clone command +carries no =--depth= for these two repos). The remote-URL half needs the +decision above, so the task as a whole waits on it. Split it in two if the +depth fix is wanted sooner. +*** 2026-08-19 Wed @ 23:05:00 -0700 Dropped --depth from both user-repo clones +=archsetup:1462= and =:1475= now clone full history; +=tests/installer-steps/test_clone_user_repos.py= covers it with 8 cases, and +one of them asserts the AUR build clones still carry =--depth 1= so the fix +can't be over-applied by a careless repo-wide sed. Both my repos on velox were +already unshallowed by hand last session, so this is prevention rather than +repair. +*** 2026-08-19 Wed @ 23:05:00 -0700 Settled the remote-URL half and netted it +See the decision recorded above. The ISO half is a note on the secrets/ISO +task; the net is =post-rebuild-check= check 8, which ships now. + +Both halves resolved. Depth: =a028aa5= drops =--depth 1= from both user-repo clones, with 8 tests including one asserting the AUR build clones stay shallow. Remote URL: decided 2026-08-19 (see above) — the personal ISO carries the ssh form, and =post-rebuild-check= check 8 (=87ff0b7=) flags any working repo still on the read-only endpoint, covering the install paths the ISO cannot reach. +** DONE [#D] Worldclock tooltip blanks on one bad timezone row :bug:dotfiles:waybar:quick:solo: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-25 +:END: +Found by sentry (2026-07-25), verified by exercising. =hyprland/.local/bin/waybar-worldclock= builds each zone with =ZoneInfo(tz)= inside the loop (line ~99) with no guard, so a single malformed timezone row in =worldclock.conf= raises =ZoneInfoNotFoundError= and crashes the whole python pass. The tooltip then renders empty and *every* zone is lost, not just the bad row; the traceback only reaches stderr, where waybar never surfaces it. +Repro: a conf with =America/Chicago|Home=, =Not/AZone|Bad=, =Europe/London|London= renders =tooltip: ""= (Home and London gone too). +Grade: minor severity (one module's tooltip blanks, no data loss) x rare edge case (a malformed conf row) = P4 = [#D]. +Fix: wrap the per-row =ZoneInfo=/=datetime= in a try/except and =continue=, so a typo drops only that row and the valid zones still render. Solo + quick: the script already has an env-override test harness (=WAYBAR_TIME_EPOCH=, =WAYBAR_WORLDCLOCK_CONF=), so a red-first test is cheap. + +Fixed in dotfiles =8f692f5=. The per-row =ZoneInfo= is guarded, so a malformed row drops itself and the valid zones still render. Five cases, including a bad row first — the ordering that looks least like one typo and most like the module being broken. Caught the broad =except Exception= rather than =ZoneInfoNotFoundError=, because the row also parses floats and calls strftime and the contract wanted is "a bad row costs only itself". +** DONE [#C] obsbot-wb-guard polls forever on machines with no OBSBOT :bug:dotfiles:quick:solo: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-08-16 +:END: +=obsbot-wb-guard.service= is =WantedBy=graphical-session.target= and lives in the shared =common/= stow tier, so it starts on every machine. Its main path is =while :; do check_once; sleep 2; done=, and =check_once= returns early when the camera node is absent. On a machine with no OBSBOT attached that is a process waking every two seconds forever to do nothing, which on a laptop is battery spend for zero benefit. No restart loop, though: the loop never exits, so =Restart=on-failure= never fires. + +Found 2026-08-16 on velox, after enabling it to match ratio and then having to disable it again by hand. A per-machine disable is the wrong shape, because it drifts velox from ratio permanently and a re-stow or a future audit will just put it back. + +Fix: give the unit =ConditionPathExists= on the camera node (=/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_PW106-video-index0=, the same default the script uses) so systemd skips it on any machine without the camera and starts it normally on ratio. Then re-enable it on velox, where it will simply be skipped. Note the limit: a camera plugged in later will not start it until the next login, which is the right trade against a permanent poll. + +Careful when disabling by hand in the meantime: =systemctl --user disable= on a *linked* unit deletes the unit symlink, and that symlink is stow-managed, so a bare disable silently removes a file from the dotfiles stow tree. Restore the link afterward or re-stow. + +Grade: minor severity (wasted wakeups and battery, no data loss, no failure) x every boot on any machine without the camera = P3 = [#C]. + +Solo: buildable here (archsetup owns dotfiles end-to-end), verifiable by the agent (assert the unit is skipped on velox and still active on ratio), and no design call left open. + +Fixed in dotfiles =566dd14=. =ConditionPathExists= on the camera node, so systemd skips the unit where the camera is absent. velox is now =enabled= like ratio and reports =ConditionResult=no=; the stow symlink is untouched. Found while doing it: ratio has a Logitech BRIO and no OBSBOT on USB at all, so the 2-second poll was pointless on the desktop too, not merely costing laptop battery. A test asserts the unit's condition path and the script's =OBSBOT_WB_DEVICE= default stay equal, since drift there is invisible in both directions. +** DONE [#C] Spine face tests decay against the wall clock :bug:test:dotfiles:solo: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-08-02 +:END: +=settings/faces/timeline-face-spine.test.mjs= has thirteen =SP.spineRows(g, h)= calls that omit the third argument, so =ref= falls back to its =new Date()= default while the file's events fixture is pinned to =JUL= (2026-07-31 18:30 UTC). Any assertion that depends on how much room the day needs is then measured against today's clock, and rots as the fixture recedes. + +One of them, "spacing is uniform everywhere except the gap home opens", had already rotted: green on 07-31 because that was the fixture's own date, red by 08-02. Fixed in place on 2026-08-02 by pinning =JUL=; the remaining thirteen pass today by luck. The measurement, for whoever picks this up — with =ref=now= the even step is 85.21 and home's gaps are 129.10 / 65.40 (the lower one collapses below a plain gap); with =ref=JUL= the step is 78.54 and the gaps are 129.10 / 145.46. Only the lower gap moves, because =up= does not depend on events and =down= does. + +Six other calls in the same file already pass =JUL= explicitly, so the convention exists and this is a miss, not a gap in the design. Fix: pass =JUL= at every call whose assertion reads geometry. Leave the call around line 747 alone — it sweeps =new Date(t0)= deliberately. + +Grade: minor severity (dev-facing only; no product behavior is wrong, the face itself is fine) x some users, sometimes (each call rots independently, whenever the fixture drifts far enough) = P3 = [#C]. Not merely cosmetic though: a suite that goes red for no real reason is how a genuine regression gets waved through. + +Solo — mechanical, an existing convention to copy, and verifiable by running the suite plus re-running it under a faked clock to prove the determinism actually holds. + + +Fixed in dotfiles =c96a216=. All thirteen bare calls now pass =JUL=. The task's "line 747" was stale (the deliberate =t0= sweep is at 893 and already passed its own ref, so it was never at risk), and the continuation-form call closes its arguments on the next line, which is why a naive grep counts fourteen. Added a guard that reads the file and fails with the offending line numbers, and verified it bites by stripping =JUL= from one call and confirming it went red naming that line. +** DONE [#A] Velox still carries the install placeholder passwords :bug:security:velox: +CLOSED: [2026-08-23 Sun] SCHEDULED: <2026-08-20 Thu> +:PROPERTIES: +:CREATED: [2026-08-20 Thu] +:LAST_REVIEWED: 2026-08-20 +:END: +Closed 2026-08-23: I'd already rotated all three on the 08-14 bringup day, so +this task was never live. Verified on velox before closing — =chage -l= puts the +last password change for both =cjennings= and =root= at Aug 14 2026, and +=/etc/zfs/zroot.key= was rewritten 2026-08-14 05:29 and no longer holds the +placeholder (checked with a =grep -qx= that returns a yes/no without reading the +key into a transcript). + +The premise below was wrong, and it's worth naming how. Nothing ever tested the +credentials: the claim came from an unticked runbook item plus the archangel +session handing back the values the *installer* had set, which reads as "these +are current" only if you assume nobody changed them in between. An inference +about a security exposure got recorded in the same voice as a measurement. The +one command that settles it costs a second. + +Original body follows. + +The 2026-08-13 reinstall set placeholder credentials and the runbook's Phase 5 +item to replace them (=passwd=, =zfs change-key zroot=) was never ticked. +Believed still live 2026-08-20 via the archangel handoff, which had to hand +them back to Craig to get into the machine: =welcome1= for the pool, =welcome= +for the accounts. + +So velox's full-disk encryption is currently protected by a dictionary word +with a digit, on the machine that travels. Anyone who picks it up owns the pool +and every account on it — the encryption is doing no work at all. + +Two commands, both on velox: +- =passwd= for each account. +- =zfs change-key zroot= for the pool passphrase. Note this is the ZBM unlock + passphrase, so get it right before rebooting. + +Grading: *severity-alone carve-out* — this is a security exposure, so the +frequency row does not discount it (=todo-format.md=). Critical severity: total +compromise of an encrypted-at-rest laptop from a guessable string, with the +device leaving the house. = P1 = [#A]. + +Distinct from the =VERIFY [#A] Rotate the credentials exposed by the 2026-08-09 +dotfiles leak= under the cgit audit — that one covers credentials a crawler +already took from a public repo. This one is a local default never changed. Both +are rotation work; neither substitutes for the other. +** CANCELLED [#B] Consistent keybinding family for the panel console :feature:hyprland: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +Merged into =[#B] Reconcile panel keybindings around Super+N=, which now carries +this body's detail: the collision list, the velox plain-keyboard constraint, and +maintenance-M as the priority chord. Cancelled rather than done — the work is +still open, just tracked in one place instead of two. + +Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard). +*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B] +Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog. +** DONE [#B] Waybar network module — custom/net :feature:waybar:network: +CLOSED: [2026-08-21 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +Closed 2026-08-21: the module shipped and is in daily use. Phases 1-4 all landed +in dotfiles, and the tunnels track absorbed most of what Phase 5 originally +covered. The one piece genuinely left — the =net vpn= CLI subcommand — is now its +own task below, so the residual is tracked at its real size instead of holding a +finished umbrella open. +Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager +dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine +(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code +lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook); +archsetup only installs deps. Secrets stay in NetworkManager's own store (no +separate credential store). The =captive= script becomes the diagnostics engine. +Full design, acceptance criteria, and the failure-mode coverage table: +[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]]. + +Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest= ++ fakes on PATH, coverage via venv); the live-network and visual states need real +conditions, filed under "Manual testing and validation". + +*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery +Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main). +The =net= engine is a src-layout Python package in-tree, imported by a bin shim +that resolves the stow symlink back to the repo — so it runs from a bare TTY with +no install, which the recovery path depends on. + +Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in +budget) + =net probe= (native captive probe, single-flight flock, atomic cache, +fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net= +replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes + +live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/ +dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal +classifications; =net portal= + the =captive --probe-json= refactor; redacted +JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/ +config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi. + +Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net +reads the airplane-mode state file and shows an airplane state/glyph. The +airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness + +services — not a radio switch), now on =custom/net='s right-click + signal 15. +Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests + +css. =airplane-mode= kept. + +Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/ +systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow- +nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32 +suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch +(classify 100%), IO-error branches excused in the test docstring. +Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2, +speedtest-go-bin Phase 3 — not added before the code that needs them). +Verify (manual, live): see Manual testing and validation. + +*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management +Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved +MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed, +mutation safety — keep prior link until target activates, classify wrong-password vs +generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK; +enterprise activate-only; secret to NM's store, never our JSON/log — tested). + +Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable +rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored +top-right under the bar — Connections section with MRU list, active marked, signal +glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread +engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free. + +Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle, +middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open +a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on +Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit); +already on velox. + +Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak +on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite +green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24 +profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/ +speedtest IN the panel) is next; the engine for it already exists from Phase 1. + +*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel +Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin), +pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns, +down/up from per-server byte rates; missing-backend / offline / malformed → error +envelope per the failure table). Panel grew a section switcher with four pages: +- Connections (Phase 2). +- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title + + redacted evidence), read-only; Open-portal button when captive. +- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test) + + force portal. Confirmations in-panel with the spec's exact wording; the privileged + tiers run via =net-popup= terminal (where the sudo prompt + step output, incl. + cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op. +- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping + + server), Run/Cancel (Cancel pkills the child), error envelope shown. + +213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full +dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit +(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The +in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege +story settles. + +The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4 +(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core +feature (indicator + recovery + panel + diagnostics + speed test) is done. +Verify (manual, live): see Manual testing and validation. + +*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: Phase 4 is filed on the dotfiles side, waiting on them +The dotfiles project accepted the Phase 4 handoff and filed it as a =[#C]= task in their own =todo.org= (their note, 2026-07-08 16:56): the help-text audit + panel help affordance, the user-guide/README, and the ratio rollout doc. Not started there. They ping when it lands, and this task's Phase 4 child closes then. Nothing to do here meanwhile. + +*** 2026-08-17 Mon @ 19:57:42 -0700 Landed on the dotfiles side; the block is cleared +dotfiles shipped it as =138da7b= and closed its own task, so this one closes +with it and the =:blocked:= tag comes off. Found by checking their =todo.org= +rather than waiting for the ping — their close-out note says "archsetup pinged +so its Phase 4 task can close", so the handoff worked and only this end was +left open. + +All three acceptance criteria are met on their side: the help audit found and +fixed a stale =net repair= action list (nine of nineteen actions were named; +both the CLI help and =repair.py='s docstring now generate from the ACTIONS +registry), =net/README.md= covers every command plus the recovery targets, and +the ratio rollout is documented with both daily drivers verified current. + +They split the panel help affordance out rather than inventing it — no sibling +panel has one, so its shape is a design call. It is tracked on their side, not +here. + +Original deliverable, for the record: in-app help (=net --help= + per-command, +panel help affordance); README/user-guide; archsetup Hyprland dep install +(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep + +stow step. Handed off 2026-07-04 with the archsetup deps already confirmed +installed. + +*** 2026-08-21 Fri @ 14:18:03 -0700 Promoted the Phase 5 residual out to its own task +Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up. +** DONE [#A] Ratio: pull .emacs.d before upgrading Emacs to 31.1 :chore:ratio:emacs: +CLOSED: [2026-08-25 Tue] +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +Emacs 31.1's warnings.el defers daemon-startup warnings into a closure holding +the =*Warnings*= buffer; the config's dashboard-only sweep killed that buffer, +so the first client frame of every fresh 31.1 daemon failed on Wayland and +emacsclient silently fell back to =$DISPLAY= (XWayland, pgtk warning dialog). +Fixed in =.emacs.d= commit =63831060= (2026-08-25, velox verified live: +=GdkWaylandDisplay=). Ratio is still on 30.2, which lacks the deferring code, +so it is fine until it upgrades — then it hits the same trap once per daemon +start unless the fix is pulled first. + +Order on ratio: =git -C ~/.emacs.d pull= (the push from velox is the telega +session's; confirm =63831060= is on origin first), then the =pacman -Syu= that +brings =emacs-wayland 31.1=, then restart the daemon. Check afterwards: +=emacsclient -e '(pgtk-backend-display-class)'= → =GdkWaylandDisplay=. + +*** 2026-08-25 18:10 — pull already landed; the upgrade half remains +Checked ratio over tailscale: =~/.emacs.d= is clean at =91fbac72= (= =origin/main=), +and =63831060= is an ancestor of HEAD — =modules/undead-buffers.el= carries the +=*Warnings*= entry. Ratio is on =emacs-wayland 30.2-3= with =31.1-1= pending among +720 updates (last full upgrade 2026-08-01; kernel 7.1.5 → 7.1.9 also pending, +btrfs root, uptime 3.5 weeks). The daemon is a plain =emacs --daemon= (not a +user unit) holding 2 live frames, so the restart step will drop those frames. +What remains: the =pacman -Syu= on ratio, the daemon restart, and the +=(pgtk-backend-display-class)= check. + +*** 2026-08-25 Tue @ 18:35:00 -0600 Upgraded ratio to Emacs 31.1 and verified the Wayland backend +Ran the upgrade over tailscale as a transient unit (=ratio-upgrade.service=, +log at =/var/log/ratio-upgrade.log=): 714 packages, =--ignore= on the six +packages the live-update guard would have blocked (aquamarine, hyprland, +hyprutils, mesa, vulkan-radeon, wayland — still pending, apply from a TTY +before the reboot). One orphan cleared first: =qemu-block-gluster= had been +dropped from the repo and pinned =qemu-common=; the new =qemu-full= no +longer needs it. Killed the plain =emacs --daemon= (no modified buffers, no +graphical frames), started =emacs.service= instead so the daemon carries the +systemd user environment, and probed from a throwaway frame: +=(pgtk-backend-display-class)= → =GdkWaylandDisplay=, =*Warnings*= alive. +Ratio still wants a reboot for =linux 7.1.9=. Pacnews to review there: +=/etc/ssh/sshd_config.pacnew= and two =/etc/tpm2-tss/fapi-profiles/*.json=. +** DONE [#B] Function keys issue media actions instead of F-keys :bug:velox: +CLOSED: [2026-09-01 Tue] +:PROPERTIES: +:CREATED: [2026-08-19 Wed] +:LAST_REVIEWED: 2026-08-19 +:END: + +From the roam inbox, Craig's words: "function keys should issue F+number +functionality rather than their media functionality when the button is hit. +currently it's reversed and I have to hit function and the f button for F+number +functionality." + +Check first whether this belongs to archsetup at all. On a Framework the Fn-lock +is a firmware-level toggle held in the keyboard itself (Fn+Esc on most +revisions), not something the OS sets, in which case this is one keystroke +rather than a change here. If it is instead a hid/keyboard-module quirk, it is +ours. + +Grading: Minor severity (the keys work, they are on the wrong layer, and there +is a workaround) x every user every time (every F-key press) = P2 = [#B]. + +Resolved 2026-09-01: not ours, as the body suspected. The Fn layer is decided +in the EC (the keyboard reaches Linux as a plain AT keyboard on i8042), so no +OS-side knob exists. One keystroke: Fn+Esc toggles Fn Lock; Craig confirmed +F1-F12 now send F-keys by default. The EC holds the state across reboots; it +reverts only if the EC loses power (battery disconnect or mainboard reset), +which is likely why it flipped around the August reinstall. diff --git a/working/hyprland-lua-port/README.org b/working/hyprland-lua-port/README.org new file mode 100644 index 0000000..42779e5 --- /dev/null +++ b/working/hyprland-lua-port/README.org @@ -0,0 +1,131 @@ +#+TITLE: Hyprland .conf → Lua port — staged, not deployed +#+AUTHOR: Craig Jennings + +* Status + +Built and verified in a nested compositor. *Not deployed.* I deployed it to the +dotfiles tree on 2026-08-24 and then rolled it back the same afternoon, because +the switch had not been checked on real hardware and the machine needs to stay +usable. The dotfiles repo is untouched at =8f692f5=; the live config is the +original =hyprland.conf=. + +The port goes live only after the hardware check in =todo.org= under "Manual +testing and validation" passes. + +* What is here + +| File | What it is | +|----------------------------+---------------------------------------------------------------| +| =hyprland.lua= | The deliverable, 788 lines. Shared config. | +| =velox-local.lua= | velox host overrides, for =velox/.config/hypr/conf.d/=. | +| =ratio-local.lua= | ratio host overrides, for =ratio/.config/hypr/conf.d/=. | +| =reader-changes-for-lua.patch= | dotfiles-side: the four readers, ported and mutation-tested. | +| =test-desktop-for-lua.patch= | archsetup-side: the post-install desktop checks. | +| =hyprland.lua.generated= | Raw =hyprlang2lua= output, merging mode. Derivation evidence. | +| =nomerge.lua= | Same converter with =--no-merge=. Derivation evidence. | + +The two patches are the part that is easy to lose and expensive to redo. Both +were mutation-tested — every ported assertion was confirmed to go red when the +property it guards was removed — so replay them rather than rewriting the +assertions from scratch. + +* Redeploy, when the port is ready + +1. =cp hyprland.lua ~/.dotfiles/hyprland/.config/hypr/hyprland.lua= +2. =cp velox-local.lua ~/.dotfiles/velox/.config/hypr/conf.d/local.lua= +3. =cp ratio-local.lua ~/.dotfiles/ratio/.config/hypr/conf.d/local.lua= +4. Move the three =.conf= files out of their stow packages. Do NOT merely leave + them beside the =.lua=: with both present Hyprland 0.56.2 loads the =.lua= + (proven — see below), so leaving the =.conf= in place buys no rollback and + only creates ambiguity about which file is live. +5. =cd ~/.dotfiles && git apply <path>/reader-changes-for-lua.patch= +6. =cd ~/code/archsetup && git apply <path>/test-desktop-for-lua.patch= +7. Restow. Expect two traps, both hit on 2026-08-24 and both documented in + =todo.org=: =make restow hyprland= aborts on the pre-existing + =obsbot-wb-guard.service= conflict in =common= (restow =hyprland= and the host + package individually instead), and the running Hyprland rewrites a stub + =hyprland.conf= within a second of the symlink vanishing. Silence the stub with + =hyprctl keyword misc:disable_autoreload 1=, do the stow, then set it back to 0. +8. *Push the dotfiles change before committing archsetup.* The installer clones + the dotfiles *remote* (=archsetup:1481=), so until the push lands a fresh VM + stows a tree with only =hyprland.conf= and the post-install checks fail. + +* Two things already proven, so nobody re-derives them + +*With both files present, the =.lua= wins.* Tested in a nested Hyprland 0.56.2 +with a fixture whose =.conf= set =gaps_in=11= and whose =.lua= set =77=. Result +was 77, and the log read "[cfg] Using lua config found at ...hyprland.lua". This +is why step 4 moves the =.conf= out rather than leaving it as a fallback. + +*The converter is a draft, not an answer.* =hyprlang2lua= +(github.com/EIonTusk/hyprlang2lua) reported 100% coverage and still produced +three functional defects, two of which would have broken the desktop. The two +generated files are kept as evidence: both still carry the unfixed bind defect +(="CTRL" .. mod .. " + S"=, which collapses to an unparseable =CTRLSUPER + S=), +and the merging-mode file shows the source glob emitted mid-file where it silently +reverses every per-host override. =--no-merge= fixed the ordering structurally; +the rest were hand-fixed. + +* Review findings folded in (2026-08-24) + +An isolated review of the deployed diff, before the rollback. Four were fixed in +the files here; the rest are gates on redeploying, not on the port's correctness. + +** Fixed here + +- =hl_source_glob= now surfaces all three failure modes and survives them. A + host override that fails to parse was warned about and skipped, which on velox + means coming up with no =force_zero_scaling= and no monitor scale — looking + like the whole port failed rather than one file. A runtime error inside the + chunk was unprotected and would have taken the entire config down over a single + host file. Now: parse failure says SKIPPED, runtime failure says PARTIAL and is + caught with =pcall=, and a glob matching nothing says NO MATCH. hyprlang did + none of this. +- The =col.nogroup_border*= rationale sat *below* the =col= table, reading as a + preamble to =layout=. Moved above the two keys it explains — the same defect + the file header says was fixed for autostart. +- =Generated by hyprlang2lua. Review TODOs before reloading Hyprland= removed + from all three files. No TODOs exist, and in =ratio-local.lua= it had landed + mid-paragraph, splitting the DP-4 rationale from the =hl.monitor= call it + explains. +- =ratio-local.lua='s usage examples were still hyprlang syntax + (=monitor=DP-1,...=, =bind = $mod, L, ...=), which are syntax errors in a Lua + file, and the second named =$mod= — a variable a sourced chunk cannot see. + Rewritten in Lua, with a note on the scoping. Verified rather than assumed: + =loadfile= gives the chunk globals only, and both =mod= and =at_start= are + locals, so both read =nil= inside a sourced file. + +** Refuted by measurement + +- *Duplicate chords append; they do not replace.* The config binds Super+Z twice + on purpose (=exec pypr zoom= plus =submap zoom=), and the same for Escape + inside the submap — if the Lua API replaced rather than appended, Super+Z would + enter the submap without zooming and the pairing the config's own comment + relies on would be broken. Tested in a nested Hyprland 0.56.2 with exactly that + shape: both binds register. The live =.conf= session registers the same pair, + so behaviour matches. No action needed; recorded so nobody re-derives it. + +** Gates on redeploy — do these as part of the switch + +1. *Do not put the =.conf= files in a =retired/= directory inside the repo + without also excluding them from =dotfiles-validate=.* Its find uses + =-path '*/.config/hypr/*.conf'=, which globs across slashes and would match + the retired copies — 81 of 228 checked references came from the dead config + when this was tried. The validator would then fail pointing at a file kept + precisely because nothing loads it. Add =-not -path "$root/retired/*"= to both + finds, or park the =.conf= files outside the repo entirely. +2. *Add tests for the new =dotfiles-validate= Lua branch.* The 25 new lines ship + with none. Proven vacuous: replacing both new awk regexes with =NEVERMATCHES= + still leaves =tests/dotfiles-validate/= reporting 15 tests OK. An extractor + that matches nothing prints nothing and exits 0 — the same false-pass shape + the three ported test suites got vacuity guards for. This one has no guard. +3. *Guard the override ordering.* Nothing asserts =hl_source_glob= is the last + statement in =hyprland.lua=, and it is the single invariant the whole per-host + layer rests on. An edit that moves it above the =hl.config= blocks silently + reverses every host override — converter defect 1, the one that would have + reverted the Qt scaling fix. archsetup's VM suite structurally cannot catch + it, because the VM stows no host tier. The guard belongs in the dotfiles repo. +4. *Sweep the prose comments that still name =hyprland.conf=.* About fifteen + across live scripts. =hyprland/.local/bin/waybar-reserve:12= is the one that + matters: it documents "Wired as =exec = waybar-reserve= in hyprland.conf", + which is exactly the mechanism the port replaces with =at_reload=. diff --git a/working/hyprland-lua-port/hyprland.lua b/working/hyprland-lua-port/hyprland.lua new file mode 100644 index 0000000..c005193 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua @@ -0,0 +1,812 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + -- + -- Both failure paths are surfaced loudly and neither is fatal. hyprlang did + -- neither, and the asymmetry matters in opposite directions: a syntax error + -- that only warns means velox comes up with no force_zero_scaling and no + -- monitor scale, which looks like the port failed rather than like one file + -- failed; and an uncaught runtime error inside the chunk would take the + -- entire config down over a single host override. So: report both, continue + -- past both. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + local matched = 0 + for f in p:lines() do + matched = matched + 1 + local chunk, err = loadfile(f) + if not chunk then + io.stderr:write("hl_source_glob: SKIPPED " .. f .. + " -- it did not parse, so nothing in it applied: " .. + tostring(err) .. "\n") + else + local ok, rerr = pcall(chunk) + if not ok then + io.stderr:write("hl_source_glob: PARTIAL " .. f .. + " -- it errored partway, so some of it applied " .. + "and the rest did not: " .. tostring(rerr) .. "\n") + end + end + end + p:close() + if matched == 0 then + io.stderr:write("hl_source_glob: NO MATCH for " .. pattern .. + " -- every host override is missing. On a machine that " .. + "has a conf.d file this means the glob is wrong.\n") + end +end + +-- Autostart collectors. Each command stays under the comment that explains it, +-- in the order hyprlang ran them; the hl.on() handlers at the bottom replay the +-- lists. Written this way because the generated form hoisted every command into +-- one block at the end of the file and left the comments stranded where the +-- commands had been -- in this config that means paragraphs of rationale with +-- no code under them, and one cross-reference ("the two exec-once lines above") +-- that had become false. +local autostart, atshutdown, atreload = {}, {}, {} +local function at_start(cmd) autostart[#autostart + 1] = cmd end +local function at_shutdown(cmd) atshutdown[#atshutdown + 1] = cmd end +local function at_reload(cmd) atreload[#atreload + 1] = cmd end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. +at_reload("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +at_start("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). +at_start("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + +-- Core services +at_start("/usr/lib/polkit-kde-authentication-agent-1") +at_start("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") +at_start("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. +at_start("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + +-- Background services +at_start("touchpad-auto") +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +at_shutdown("pkill -x hypridle") +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +at_start("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("/usr/lib/geoclue-2.0/demos/agent") +at_start("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("mpd") +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. +at_start("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Pyprland (scratchpads, magnify, etc.) +at_start("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("hypr-refocus-scratchpad") + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +at_start("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) +at_start("env QT_FONT_DPI=108 protonmail-bridge --no-window") + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes + -- their border through col.nogroup_border* instead of col.*_border. + -- Without these overrides Hyprland's defaults paint scratchpads + -- bright magenta. + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + layout = "master", + resize_on_border = true, + }, +}) + +hl.config({ + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, +}) + +hl.config({ + animations = { + enabled = true, + }, +}) + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +-- ============================================================================ +-- Layout (master-stack like DWM tile) +-- ============================================================================ + +hl.config({ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, +}) + +hl.config({ + dwindle = { + preserve_split = true, + }, +}) + +-- ============================================================================ +-- Input +-- ============================================================================ + +hl.config({ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, +}) + +hl.config({ + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, +}) + +-- ============================================================================ +-- Misc +-- ============================================================================ + +hl.config({ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, +}) + +-- ============================================================================ +-- Debug (temporary - disable when stable) +-- ============================================================================ + +hl.config({ + debug = { + disable_logs = false, + }, +}) + +-- ============================================================================ +-- XWayland +-- ============================================================================ + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) + +-- ============================================================================ +-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) +-- ============================================================================ +-- Floating windows (from DWM rules) +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +-- net / bluetooth instrument-console panels. Normal floating windows (formerly +-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. +-- Opened top-right to match their old anchored spot: the panel is right-aligned +-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + move = "100%-464 50", +}) + +-- maintenance console: the wide board (960), same right-aligned convention. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + move = "100%-1004 50", +}) + +-- org-capture popup frame (quick-capture script names the frame) +-- Size is per-host in <host>/conf.d/local.lua: native window rules ignore +-- percentages (only pyprland honors them), so the popup is sized in absolute +-- pixels matching that host's terminal scratchpad. No size rule here means a +-- host without an override falls back to the script's char-cell geometry. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + center = true, +}) + +-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — +-- it's a file manager that launches files into other apps, so focus must be free +-- to follow; q (cj/dirvish-popup-quit) closes the frame. +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + size = "1100 700", +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + center = true, +}) + +-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion +-- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + +-- Gaming +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +-- ============================================================================ +-- Key Bindings +-- ============================================================================ +local mod = "SUPER" + +-- Terminal and core apps (from DWM) +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +-- Standalone emacs: its own process, not a frame on the daemon, so killing it +-- takes no other frame with it. init.el guards server-start on server-running-p, +-- so while the daemon holds the socket this process leaves it alone. With no +-- daemon up it finds no server and becomes one -- the guard lives in init.el, and +-- a keybind can't override it, since --eval runs after init. +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +-- From sxhkdrc +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +-- Window management (from DWM) +-- Layout-aware navigation (works across master, scrolling) +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +-- Swap focused window with master, then force focus onto the master slot. +-- swapwithmaster's own `master` focus param doesn't stick when invoked from +-- the master, so focusmaster master pins focus afterward. The 50ms sleep is +-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it +-- returns; without the delay that event lands AFTER focusmaster and flips +-- focus back to the detail. The sleep lets the swap's focus settle so +-- focusmaster runs last and wins. Proven via instrumented capture (19/19). +hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("hyprctl dispatch layoutmsg swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +-- Layouts: master -> monocle +-- Cycle with Shift+arrows, or jump directly with Shift+T/M +-- (scrolling layout disabled until frame-fit + wrap-around work lands) +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +-- Master layout adjustments +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +-- Stash windows (hide to special workspace) +-- O = stash focused / Alt+O = stash others / Shift+O = restore all +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +-- Gaps between windows only; window-gaps leaves the monitor-edge gap +-- (general:gaps_out) fixed, so widening/narrowing moves the space between +-- windows, not the screen-edge margin. +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +-- Caffeine (keep-awake) toggle. Same action as clicking the waybar +-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't +-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate +-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a +-- misclick away. The custom/net module shows the state; this toggles it. +-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +-- Collapse / expand the left or right side of the bar to its base set +-- (same action as clicking the side's arrowhead). [ = left, ] = right. +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +-- Fullscreen +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +-- Workspaces 1-9 (from DWM TAGKEYS) +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +-- Move window to workspace (from DWM tag) +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +-- Monitor focus (from DWM focusmon) +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = "-1" })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +-- ============================================================================ +-- Scratchpads (via pyprland) +-- ============================================================================ +-- Configured in ~/.config/hypr/pyprland.toml +-- Uses normal workspaces (not special), so new windows won't be captured +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +-- Magnify (zoom) +-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z +-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` +-- (factor 1) so submap state and zoom state can't desync. Note: while +-- zoomed, other Hyprland binds pause until you exit the submap. +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +-- Calculator (not a scratchpad, just launches app) +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +-- Media/hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). +-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now +-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). +-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +-- Bluetooth panel (blueman retired in favor of the bt panel) +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +-- Screenshots (grim + slurp + fuzzel menu) +-- Shift+S captures the whole desktop with no pointer interaction, so it +-- works on scratchpads and popups that region-select would dismiss +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL + " .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +-- Lock screen +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +-- Audio mute cycle (M for Mute): one key walks volume/mic through all four +-- on/off combinations. The waybar pulseaudio modules follow via PipeWire. +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +-- Exit/session +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" +-- submap, where a second Backspace confirms and anything else backs out. +-- The bare bind killed two live sessions on 2026-07-22: it sits one key from +-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) +-- and took the whole session down with no prompt. wlogout owns the normal +-- exit path; this stays as the keyboard escape hatch for when wlogout won't +-- come up, so it's gated rather than removed. +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT + " .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +-- Mouse bindings (from DWM buttons) +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true }) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.lua. + +-- Replay the collected autostart commands. +hl.on("hyprland.start", function() + for _, cmd in ipairs(autostart) do hl.exec_cmd(cmd) end +end) + +hl.on("hyprland.shutdown", function() + for _, cmd in ipairs(atshutdown) do hl.exec_cmd(cmd) end +end) + +-- hyprlang's `exec` ran at startup AND on every reload. config.reloaded fires on +-- the initial load too (verified in a nested Hyprland 0.56.2 on 2026-08-24: a +-- marker written from this handler appears at session start), so this single +-- handler covers both cases, which is what waybar-reserve needs. +hl.on("config.reloaded", function() + for _, cmd in ipairs(atreload) do hl.exec_cmd(cmd) end +end) + +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") diff --git a/working/hyprland-lua-port/hyprland.lua.generated b/working/hyprland-lua-port/hyprland.lua.generated new file mode 100644 index 0000000..04b30b9 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua.generated @@ -0,0 +1,674 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). + +-- Core services + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. + +-- Background services +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. + +-- Pyprland (scratchpads, magnify, etc.) + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, + move = "100%-1004 50", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, + center = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, + size = "1100 700", + center = true, +}) + +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +local mod = "SUPER" + +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their + -- border through col.nogroup_border* instead of col.*_border. Without + -- these overrides Hyprland's defaults paint scratchpads bright magenta. + layout = "master", + resize_on_border = true, + }, + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, + animations = { + enabled = true, + }, + -- ============================================================================ + -- Layout (master-stack like DWM tile) + -- ============================================================================ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, + dwindle = { + preserve_split = true, + }, + -- ============================================================================ + -- Input + -- ============================================================================ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, + -- ============================================================================ + -- Misc + -- ============================================================================ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, + -- ============================================================================ + -- Debug (temporary - disable when stable) + -- ============================================================================ + debug = { + disable_logs = false, + }, + -- ============================================================================ + -- XWayland + -- ============================================================================ + xwayland = { + force_zero_scaling = true, + }, + -- ============================================================================ + -- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) + -- ============================================================================ + -- Floating windows (from DWM rules) + -- net / bluetooth instrument-console panels. Normal floating windows (formerly + -- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. + -- Opened top-right to match their old anchored spot: the panel is right-aligned + -- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. + -- maintenance console: the wide board (960), same right-aligned convention. + -- org-capture popup frame (quick-capture script names the frame) + -- Size is per-host in <host>/conf.d/local.conf: native window rules ignore + -- percentages (only pyprland honors them), so the popup is sized in absolute + -- pixels matching that host's terminal scratchpad. No size rule here means a + -- host without an override falls back to the script's char-cell geometry. + -- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — + -- it's a file manager that launches files into other apps, so focus must be free + -- to follow; q (cj/dirvish-popup-quit) closes the frame. + -- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion + -- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + -- Gaming + -- ============================================================================ + -- Key Bindings + -- ============================================================================ + -- Terminal and core apps (from DWM) + -- Standalone emacs: its own process, not a frame on the daemon, so killing it + -- takes no other frame with it. init.el guards server-start on server-running-p, + -- so while the daemon holds the socket this process leaves it alone. With no + -- daemon up it finds no server and becomes one -- the guard lives in init.el, and + -- a keybind can't override it, since --eval runs after init. + -- From sxhkdrc + -- Window management (from DWM) + -- Layout-aware navigation (works across master, scrolling) + -- Swap focused window with master, then force focus onto the master slot. + -- swapwithmaster's own `master` focus param doesn't stick when invoked from + -- the master, so focusmaster master pins focus afterward. The 50ms sleep is + -- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it + -- returns; without the delay that event lands AFTER focusmaster and flips + -- focus back to the detail. The sleep lets the swap's focus settle so + -- focusmaster runs last and wins. Proven via instrumented capture (19/19). + -- Layouts: master -> monocle + -- Cycle with Shift+arrows, or jump directly with Shift+T/M + -- (scrolling layout disabled until frame-fit + wrap-around work lands) + -- Master layout adjustments + -- Stash windows (hide to special workspace) + -- O = stash focused / Alt+O = stash others / Shift+O = restore all + -- Gaps between windows only; window-gaps leaves the monitor-edge gap + -- (general:gaps_out) fixed, so widening/narrowing moves the space between + -- windows, not the screen-edge margin. + -- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. + -- Caffeine (keep-awake) toggle. Same action as clicking the waybar + -- custom/caffeine icon — flips the hypridle daemon so the screen will / won't + -- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). + -- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate + -- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a + -- misclick away. The custom/net module shows the state; this toggles it. + -- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. + -- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). + -- Collapse / expand the left or right side of the bar to its base set + -- (same action as clicking the side's arrowhead). [ = left, ] = right. + -- Fullscreen + -- Workspaces 1-9 (from DWM TAGKEYS) + -- Move window to workspace (from DWM tag) + -- Monitor focus (from DWM focusmon) + -- ============================================================================ + -- Scratchpads (via pyprland) + -- ============================================================================ + -- Configured in ~/.config/hypr/pyprland.toml + -- Uses normal workspaces (not special), so new windows won't be captured + -- Magnify (zoom) + -- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z + -- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` + -- (factor 1) so submap state and zoom state can't desync. Note: while + -- zoomed, other Hyprland binds pause until you exit the submap. + -- Calculator (not a scratchpad, just launches app) + -- Media/hardware keys + -- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). + -- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now + -- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). + -- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). + -- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). + -- Bluetooth panel (blueman retired in favor of the bt panel) + -- Screenshots (grim + slurp + fuzzel menu) + -- Shift+S captures the whole desktop with no pointer interaction, so it + -- works on scratchpads and popups that region-select would dismiss + -- Lock screen + -- Audio mute cycle (M for Mute): one key walks volume/mic through all four + -- on/off combinations. The waybar pulseaudio modules follow via PipeWire. + -- Exit/session + -- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" + -- submap, where a second Backspace confirms and anything else backs out. + -- The bare bind killed two live sessions on 2026-07-22: it sits one key from + -- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) + -- and took the whole session down with no prompt. wlogout owns the normal + -- exit path; this stays as the keyboard escape hatch for when wlogout won't + -- come up, so it's gated rather than removed. + -- Mouse bindings (from DWM buttons) + -- ============================================================================ + -- Machine-local overrides + -- ============================================================================ + -- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) + -- override the defaults above. See conf.d/local.conf. +}) + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/nomerge.lua b/working/hyprland-lua-port/nomerge.lua new file mode 100644 index 0000000..c85fd24 --- /dev/null +++ b/working/hyprland-lua-port/nomerge.lua @@ -0,0 +1,768 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). + +-- Core services + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. + +-- Background services +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. + +-- Pyprland (scratchpads, magnify, etc.) + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their + -- border through col.nogroup_border* instead of col.*_border. Without + -- these overrides Hyprland's defaults paint scratchpads bright magenta. + layout = "master", + resize_on_border = true, + }, +}) + +hl.config({ + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, +}) + +hl.config({ + animations = { + enabled = true, + }, +}) + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +-- ============================================================================ +-- Layout (master-stack like DWM tile) +-- ============================================================================ + +hl.config({ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, +}) + +hl.config({ + dwindle = { + preserve_split = true, + }, +}) + +-- ============================================================================ +-- Input +-- ============================================================================ + +hl.config({ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, +}) + +hl.config({ + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, +}) + +-- ============================================================================ +-- Misc +-- ============================================================================ + +hl.config({ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, +}) + +-- ============================================================================ +-- Debug (temporary - disable when stable) +-- ============================================================================ + +hl.config({ + debug = { + disable_logs = false, + }, +}) + +-- ============================================================================ +-- XWayland +-- ============================================================================ + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) + +-- ============================================================================ +-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) +-- ============================================================================ +-- Floating windows (from DWM rules) +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +-- net / bluetooth instrument-console panels. Normal floating windows (formerly +-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. +-- Opened top-right to match their old anchored spot: the panel is right-aligned +-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + move = "100%-464 50", +}) + +-- maintenance console: the wide board (960), same right-aligned convention. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + move = "100%-1004 50", +}) + +-- org-capture popup frame (quick-capture script names the frame) +-- Size is per-host in <host>/conf.d/local.conf: native window rules ignore +-- percentages (only pyprland honors them), so the popup is sized in absolute +-- pixels matching that host's terminal scratchpad. No size rule here means a +-- host without an override falls back to the script's char-cell geometry. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + center = true, +}) + +-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — +-- it's a file manager that launches files into other apps, so focus must be free +-- to follow; q (cj/dirvish-popup-quit) closes the frame. +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + size = "1100 700", +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + center = true, +}) + +-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion +-- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + +-- Gaming +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +-- ============================================================================ +-- Key Bindings +-- ============================================================================ +local mod = "SUPER" + +-- Terminal and core apps (from DWM) +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +-- Standalone emacs: its own process, not a frame on the daemon, so killing it +-- takes no other frame with it. init.el guards server-start on server-running-p, +-- so while the daemon holds the socket this process leaves it alone. With no +-- daemon up it finds no server and becomes one -- the guard lives in init.el, and +-- a keybind can't override it, since --eval runs after init. +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +-- From sxhkdrc +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +-- Window management (from DWM) +-- Layout-aware navigation (works across master, scrolling) +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +-- Swap focused window with master, then force focus onto the master slot. +-- swapwithmaster's own `master` focus param doesn't stick when invoked from +-- the master, so focusmaster master pins focus afterward. The 50ms sleep is +-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it +-- returns; without the delay that event lands AFTER focusmaster and flips +-- focus back to the detail. The sleep lets the swap's focus settle so +-- focusmaster runs last and wins. Proven via instrumented capture (19/19). +hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +-- Layouts: master -> monocle +-- Cycle with Shift+arrows, or jump directly with Shift+T/M +-- (scrolling layout disabled until frame-fit + wrap-around work lands) +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +-- Master layout adjustments +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +-- Stash windows (hide to special workspace) +-- O = stash focused / Alt+O = stash others / Shift+O = restore all +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +-- Gaps between windows only; window-gaps leaves the monitor-edge gap +-- (general:gaps_out) fixed, so widening/narrowing moves the space between +-- windows, not the screen-edge margin. +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +-- Caffeine (keep-awake) toggle. Same action as clicking the waybar +-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't +-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate +-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a +-- misclick away. The custom/net module shows the state; this toggles it. +-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +-- Collapse / expand the left or right side of the bar to its base set +-- (same action as clicking the side's arrowhead). [ = left, ] = right. +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +-- Fullscreen +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +-- Workspaces 1-9 (from DWM TAGKEYS) +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +-- Move window to workspace (from DWM tag) +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +-- Monitor focus (from DWM focusmon) +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +-- ============================================================================ +-- Scratchpads (via pyprland) +-- ============================================================================ +-- Configured in ~/.config/hypr/pyprland.toml +-- Uses normal workspaces (not special), so new windows won't be captured +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +-- Magnify (zoom) +-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z +-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` +-- (factor 1) so submap state and zoom state can't desync. Note: while +-- zoomed, other Hyprland binds pause until you exit the submap. +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +-- Calculator (not a scratchpad, just launches app) +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +-- Media/hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). +-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now +-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). +-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +-- Bluetooth panel (blueman retired in favor of the bt panel) +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +-- Screenshots (grim + slurp + fuzzel menu) +-- Shift+S captures the whole desktop with no pointer interaction, so it +-- works on scratchpads and popups that region-select would dismiss +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +-- Lock screen +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +-- Audio mute cycle (M for Mute): one key walks volume/mic through all four +-- on/off combinations. The waybar pulseaudio modules follow via PipeWire. +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +-- Exit/session +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" +-- submap, where a second Backspace confirms and anything else backs out. +-- The bare bind killed two live sessions on 2026-07-22: it sits one key from +-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) +-- and took the whole session down with no prompt. wlogout owns the normal +-- exit path; this stays as the keyboard escape hatch for when wlogout won't +-- come up, so it's gated rather than removed. +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +-- Mouse bindings (from DWM buttons) +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.conf. +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/ratio-local.lua b/working/hyprland-lua-port/ratio-local.lua new file mode 100644 index 0000000..de190d5 --- /dev/null +++ b/working/hyprland-lua-port/ratio-local.lua @@ -0,0 +1,43 @@ +-- ratio — desktop, 1x scaling. Defaults in hyprland.lua are correct. +-- Sourced via conf.d/*.lua glob (last wins). +-- +-- Examples: +-- hl.monitor({ output = "DP-1", mode = "3440x1440@144", position = "auto", scale = 1 }) +-- hl.bind("SUPER + L", hl.dsp.exec_cmd("hyprlock")) +-- +-- Spell the modifier out. A sourced file is loaded by hl_source_glob via +-- loadfile, which gives the chunk globals only, so the shared config's `mod` +-- (and `at_start`, and the other locals) are NOT in scope here. + +-- DP-4 (Dell U3419W ultrawide) pinned to its native mode. Without an explicit +-- pin the shared catch-all monitor=,preferred,auto,auto lets an XWayland surface +-- (emacs runs X11-only on ratio) drive the mode down to 1280x720 at login. That +-- low mode also wipes DP-4's reserved area, which drops the non-exclusive waybar +-- off-screen (margin-top:-54 needs the 60px top reserve). Pinning holds both. + +hl.monitor({ + output = "DP-4", + mode = "3440x1440@60", + position = "0x0", + scale = "1", +}) + +-- org-capture popup: capped at 120 Emacs columns wide, height proportional +-- (Craig, 2026-07-14). 120 cols x 11 px/col = 1320 wide; height keeps the old +-- rule's aspect (1892:936) = 653 px (~27 lines at 24 px). The old 55% x 65% +-- scratchpad match (1892 x 936) was the "grows too large" complaint. max_size +-- holds the cap even if the frame tries to grow with capture content. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1320 653", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1320 653", +}) + diff --git a/working/hyprland-lua-port/reader-changes-for-lua.patch b/working/hyprland-lua-port/reader-changes-for-lua.patch new file mode 100644 index 0000000..747b047 --- /dev/null +++ b/working/hyprland-lua-port/reader-changes-for-lua.patch @@ -0,0 +1,241 @@ +diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate +index 57a7505..d5e6695 100755 +--- a/common/.local/bin/dotfiles-validate ++++ b/common/.local/bin/dotfiles-validate +@@ -3,6 +3,7 @@ + # + # Walks the tree and extracts the commands that configs promise to launch: + # - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD ++# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD") + # - waybar config: "exec(-if)", "on-click*", "on-scroll-*", + # "on-double-click" values + # - systemd user units: Exec*= lines (leading -/@ modifiers stripped) +@@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f + ' "$f" + done >> "$refs_file" + ++# --- hypr lua configs: autostart collectors and exec_cmd dispatchers --- ++# The Lua config manager (Hyprland 0.55+) spells the same two things as function ++# calls rather than assignments, so the .conf walk above sees none of them. Only ++# the first word is taken, as everywhere else here. The two matches are written ++# out rather than folded into a helper: awk cannot take a regex literal as a ++# function parameter -- it collapses to a boolean match against $0, which ++# silently turns every line into a bogus reference. ++find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do ++ awk -v file="$f" ' ++ match($0, /at_(start|shutdown|reload)\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ match($0, /hl\.dsp\.exec_cmd\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ ' "$f" ++done >> "$refs_file" ++ + # --- waybar configs: command-bearing JSON values --- + find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do + awk -v file="$f" ' +diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py +index 1454817..3740024 100644 +--- a/tests/layout-cycle/test_layout_cycle.py ++++ b/tests/layout-cycle/test_layout_cycle.py +@@ -18,6 +18,7 @@ Run from repo root: + + import json + import os ++import re + import subprocess + import tempfile + import unittest +@@ -25,7 +26,7 @@ import unittest + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle") + FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl") +-HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + FLASH = "rgba(ffd24aff)" + ACTIVE = "rgba(daa520ff)" +@@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness): + + + class TestScrollLayoutDisabledInConfig(unittest.TestCase): +- """Pin the hyprland.conf half of the scrolling disable. ++ """Pin the hyprland.lua half of the scrolling disable. + + The script tests above prove the ring skips scrolling; these prove no + keybinding reaches it either, and that the freed Super+Shift+S chord +@@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + @classmethod + def setUpClass(cls): +- with open(HYPRLAND_CONF) as f: ++ with open(HYPRLAND_CFG) as f: + cls.conf = f.read() ++ # Every bind in the Lua config is a top-level hl.bind() call; the ++ # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde ++ # are the same call with an options table, so one prefix covers them. + cls.binds = [l for l in cls.conf.splitlines() +- if l.strip().startswith(("bind", "bindl", "bindm"))] ++ if l.strip().startswith("hl.bind")] ++ ++ def test_the_binds_were_actually_found(self): ++ """Guard the guard: a renamed call would empty the list and make both ++ assertions below pass against nothing.""" ++ self.assertGreater(len(self.binds), 50, ++ "found almost no hl.bind lines -- the two assertions " ++ "below would pass vacuously") + + def test_no_bind_selects_scrolling_layout(self): + offenders = [l for l in self.binds if "general:layout scrolling" in l] +@@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + def test_super_shift_s_is_fullscreen_screenshot(self): + shift_s = [l for l in self.binds +- if "$mod SHIFT, S," in l] ++ if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)] + self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}") + self.assertIn("screenshot fullscreen", shift_s[0]) + +diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py +index a989d52..b2a2721 100644 +--- a/tests/settings/test_session_restore.py ++++ b/tests/settings/test_session_restore.py +@@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase): + script and passing tests while never being placed in the bar. + """ + +- CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++ CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + def _lines(self): ++ """The autostart commands, in launch order. ++ ++ The Lua config collects startup commands with at_start("CMD") and one ++ hl.on("hyprland.start") handler at the bottom replays the list in order, ++ so position in this file is still position at launch -- which is what ++ the ordering assertion below reads. Returning the command strings rather ++ than raw lines means every entry here is by construction an autostart ++ command, so the old startswith("exec-once") filter has nothing left to do. ++ """ ++ out = [] + with open(self.CONF) as f: +- return [ln.strip() for ln in f +- if ln.strip() and not ln.strip().startswith("#")] ++ for ln in f: ++ m = re.search(r'at_start\("(.*)"\)', ln.strip()) ++ if m: ++ out.append(m.group(1)) ++ return out ++ ++ def test_the_autostart_commands_were_actually_found(self): ++ """Guard the guard: a renamed collector would empty the list and make ++ every assertion below pass against nothing.""" ++ self.assertGreater(len(self._lines()), 10, ++ "found almost no at_start commands -- the assertions " ++ "below would pass vacuously") + + @staticmethod + def _is_toggle_restore(line): +@@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase): + + def test_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and self._is_toggle_restore(ln) +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore` — " ++ any(self._is_toggle_restore(ln) for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore` — " + "remembered toggles would never be replayed") + + def test_wallpaper_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore-wallpaper` " ++ any("settings restore-wallpaper" in ln for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore-wallpaper` " + "— the stored wallpaper would never be put back") + + def test_the_toggle_restore_runs_only_once(self): + """Twice means the whole re-assert budget is spent twice per login.""" +- lines = [ln for ln in self._lines() +- if ln.startswith("exec-once") and self._is_toggle_restore(ln)] ++ lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)] + self.assertEqual(len(lines), 1, lines) + + def test_restore_is_ordered_after_the_backings_it_corrects(self): +@@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase): + # burn attempts on backings that aren't up yet. + lines = self._lines() + restore = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") +- and self._is_toggle_restore(ln)) ++ if self._is_toggle_restore(ln)) + for backing in ("hypridle", "dunst"): +- launch = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") and backing in ln) ++ launch = next(i for i, ln in enumerate(lines) if backing in ln) + self.assertLess(launch, restore, + f"`settings restore` is ordered before {backing}") + + + class DimEnv(TempEnv): +- """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is.""" ++ """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is.""" + + def setUp(self): + super().setUp() +diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py +index 7421692..fbeb95d 100644 +--- a/tests/waybar-reserve/test_reserve_pairing.py ++++ b/tests/waybar-reserve/test_reserve_pairing.py +@@ -26,7 +26,7 @@ import unittest + + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config") +-HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + + def waybar_config(): +@@ -35,13 +35,19 @@ def waybar_config(): + + + def reserve_exec_wired(): +- """True when hyprland.conf runs waybar-reserve via exec (not exec-once). ++ """True when the config re-runs waybar-reserve on every config reload. + +- Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the +- reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2; +- waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``.""" ++ Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the ++ reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2; ++ waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``. ++ ++ at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on ++ every reload, and the Lua port splits those two jobs. at_reload's handler is ++ ``hl.on("config.reloaded")``, which fires on the initial load as well, so it ++ alone covers both. Matching at_start instead would pass while reservations ++ died on the next reload -- which is exactly the defect this guards.""" + with open(HYPR) as f: +- return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f) ++ return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f) + + + def reserve_target(): +@@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase): + + def test_hyprland_wires_the_reserve_script(self): + self.assertTrue(reserve_exec_wired(), +- "hyprland.conf lacks `exec = waybar-reserve`: reservations " ++ "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations " + "die on the next config reload") + + def test_reserve_target_covers_the_bar(self): diff --git a/working/hyprland-lua-port/test-desktop-for-lua.patch b/working/hyprland-lua-port/test-desktop-for-lua.patch new file mode 100644 index 0000000..5bf7612 --- /dev/null +++ b/working/hyprland-lua-port/test-desktop-for-lua.patch @@ -0,0 +1,50 @@ +diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py +index 1538d6a..ca2f266 100644 +--- a/scripts/testing/tests/test_desktop.py ++++ b/scripts/testing/tests/test_desktop.py +@@ -11,6 +11,8 @@ installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so + this checks awww. That divergence from the shell sweep is a correctness fix. + """ + ++import re ++ + import pytest + + +@@ -19,8 +21,12 @@ HYPRLAND_TOOLS = [ + "awww", "grim", "slurp", "gammastep", "foot", + ] + ++# Note: hyprland.lua sits beside two .conf files on purpose. Only Hyprland's own ++# config format is deprecated (removed in 0.57); hypridle and hyprlock are ++# separate projects still on hyprlang, so their .conf names are current. Don't ++# "fix" this list for consistency -- that reverts the Lua port. + HYPRLAND_CONFIGS = [ +- ".config/hypr/hyprland.conf", ++ ".config/hypr/hyprland.lua", + ".config/hypr/hypridle.conf", + ".config/hypr/hyprlock.conf", + ".config/waybar/config", +@@ -139,8 +145,19 @@ def test_bt_panel_wired(host, hyprland_installed, home): + waybar = host.file("%s/.config/waybar/config" % home) + assert "custom/bluetooth" in waybar.content_string, \ + "waybar config lacks the custom/bluetooth module" +- hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home) +- assert "bt-panel" in hyprconf.content_string, \ +- "hyprland.conf lacks the bt-panel keybind" ++ hypr = host.file("%s/.config/hypr/hyprland.lua" % home).content_string ++ # Assert the dispatcher and the chord, not the bare binary name. "bt-panel" ++ # on its own still appears inside exec_cmd() when the chord that reaches it ++ # is mangled, so the weak form passes on a config where Super+Shift+B does ++ # nothing. That is not hypothetical: converting this config to Lua produced ++ # exactly that defect elsewhere ("CTRL" .. mod .. " + S" collapsing to an ++ # unparseable CTRLSUPER + S, which Hyprland rejects at parse and leaves dead). ++ # One pattern spanning the whole bind, not two independent checks: asserting ++ # the chord and the dispatcher separately passes when they sit on different ++ # lines, and a planned reshuffle of the panel keybinding family is exactly ++ # the change that would separate them. ++ assert re.search( ++ r'hl\.bind\(\s*mod\s*\.\.\s*" \+ SHIFT \+ B",\s*hl\.dsp\.exec_cmd\("bt-panel"\)', ++ hypr), "hyprland.lua lacks the Super+Shift+B bind reaching bt-panel" + assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \ + "shared panel css missing from the stowed theme" diff --git a/working/hyprland-lua-port/velox-local.lua b/working/hyprland-lua-port/velox-local.lua new file mode 100644 index 0000000..1da7359 --- /dev/null +++ b/working/hyprland-lua-port/velox-local.lua @@ -0,0 +1,64 @@ +-- velox — Framework 13, HiDPI 2256x1504. Sourced via conf.d/*.lua glob; +-- values here override hyprland.lua (last wins). + +hl.monitor({ + output = "eDP-1", + mode = "preferred", + position = "auto", + scale = "1.566667", +}) + +-- Scaling on this panel comes from the compositor alone. +-- +-- There used to be `env = GDK_SCALE,1.5` and `env = QT_SCALE_FACTOR,1.5` here, +-- to compensate for `xwayland:force_zero_scaling = true` in hyprland.lua, which +-- makes XWayland clients render unscaled and therefore tiny. That worked for +-- XWayland and broke everything else, because env vars reach every app, not just +-- the XWayland ones. Qt 6 and GTK on Wayland already take their scale from the +-- compositor, so the factor multiplied the monitor scale above: Qt saw a 960x640 +-- logical screen instead of 1440x960 and drew half again too large. Measured +-- with QScreen.geometry on 2026-08-19; 2256/1.566667 is exactly 1440, which is +-- what Qt reports once nothing overrides it. +-- +-- Turning force_zero_scaling off for this host is the other half. XWayland then +-- scales through the compositor, so those apps come out the right size and a +-- little soft rather than sharp and tiny. That cost lands only on XWayland, +-- which is the thing I avoid anyway, and correctly-sized beats crisp for the +-- occasional Zoom window the browser spawns. +-- +-- ratio needs neither: its monitor runs at scale 1, so nothing multiplies and +-- XWayland has nothing to compensate for. +hl.config({ + xwayland = { + force_zero_scaling = false, + }, +}) + +-- calibre renders oversized at the 1.57 compositor scale. Pin its own DPI so its +-- UI is comfortable without touching the desktop or other apps (validated at 96, +-- 2026-06-27). CALIBRE_OVERRIDE_DPI is calibre-only, so a session-wide env is safe. +hl.env("CALIBRE_OVERRIDE_DPI", "96") +-- No XCURSOR_SIZE here: Hyprland scales the native cursor by the monitor scale +-- already, so the shared default (24) renders correctly on HiDPI. Pre-scaling it +-- (the old 36 = 24 x 1.5) double-applied on top of the compositor's scale. + +-- org-capture popup: match the terminal scratchpad (75% x 70% of the 1437x958 +-- logical desktop = 1078 x 671 px). Native window rules ignore percentages, so +-- it's pinned in pixels here rather than in the shared hyprland.lua. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1078 671", +}) + +-- Growth cap (Craig, 2026-07-14): the frame must never outgrow its pinned +-- size even when capture content pushes it. 1078 px is ~98 Emacs columns, +-- already under the 120-column cap ratio uses. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1078 671", +}) + diff --git a/working/velox-reinstall/velox-reinstall-runbook.org b/working/velox-reinstall/velox-reinstall-runbook.org index 02671e2..0c14946 100644 --- a/working/velox-reinstall/velox-reinstall-runbook.org +++ b/working/velox-reinstall/velox-reinstall-runbook.org @@ -22,8 +22,15 @@ Fallback ordering if the test finds a real gap: the microcode vendor-detection fix (archsetup, 2026-08-08) and was built without ARCHSETUP_DIR at all. #+begin_src sh - cd ~/code/archangel && sudo ARCHSETUP_DIR=~/code/archsetup ./build.sh + cd ~/code/archangel && sudo ARCHSETUP_DIR="$HOME/code/archsetup" ./build.sh #+end_src + Two traps in that one line, and either alone silently produces a bare ISO + with archsetup absent (archangel, 2026-08-20). The =VAR=value= form is + required because sudo's =env_reset= discards an exported variable. And + =$HOME= is required because zsh does not expand a tilde on the right-hand + side of an assignment — the earlier =ARCHSETUP_DIR=~/code/archsetup= here + passed the literal string. build.sh now warns and reports baked/not-baked + in its closing summary, so the failure is visible rather than silent. - [ ] build.sh fixes before the final rebuild (archangel repo): - rsync exclude for =.ai= (keeps =archsetup/.ai/private-design/= — the credential audit — off the portable USB stick). diff --git a/working/velox-reinstall/velox-uefi-boot-entry-reference.org b/working/velox-reinstall/velox-uefi-boot-entry-reference.org new file mode 100644 index 0000000..8d677da --- /dev/null +++ b/working/velox-reinstall/velox-uefi-boot-entry-reference.org @@ -0,0 +1,75 @@ +#+TITLE: Velox UEFI Boot Entry — Recovery Reference +#+AUTHOR: Craig Jennings +#+DATE: 2026-08-15 + +Captured 2026-08-15 before a BIOS update (03.05 → 04.02) as insurance against +the update clearing NVRAM. Velox's mainboard swap on 2026-08-13 left exactly +this kind of empty NVRAM, which is what forced the reinstall — so a cleared +boot entry is the specific failure worth being able to undo in one command +rather than reconstruct. + +* State at capture + +- BIOS: 03.05 (2025-10-30) +- BootCurrent: 0001 +- BootOrder: 2001,0001,2002,2003 (USB ahead of ZBM — why the Ventoy stick + boots when it's inserted) +- Timeout: 0 seconds + +* The entry that matters + +=Boot0001* ZFSBootMenu= + +| field | value | +|----------------+----------------------------------------------| +| ESP part GUID | 8e51b680-f90a-444f-8da5-7e4f93625775 | +|----------------+----------------------------------------------| +| partition | 1 (GPT), start 0x800, size 0x100000 | +|----------------+----------------------------------------------| +| loader path | =\EFI\ZBM\zfsbootmenu.efi= | +|----------------+----------------------------------------------| +| cmdline (data) | =spl_hostid=0x22f8a7a1 zbm.timeout=3= | +| | =zbm.prefer=zroot zbm.import_policy=hostid= | +|----------------+----------------------------------------------| + +The =data= field is that command line in UTF-16LE, which is how efibootmgr +passes it as optional data. Recreate with =-u= and the plain string; efibootmgr +does the encoding. + +* Recreating it + +From a booted system (or the archangel ISO), with the ESP identified as +=/dev/nvme0n1p1= or whatever it enumerates as: + +#+begin_src bash +efibootmgr --create \ + --disk /dev/nvme0n1 --part 1 \ + --label "ZFSBootMenu" \ + --loader '\EFI\ZBM\zfsbootmenu.efi' \ + --unicode 'spl_hostid=0x22f8a7a1 zbm.timeout=3 zbm.prefer=zroot zbm.import_policy=hostid' +#+end_src + +Confirm the disk/part against =lsblk -o NAME,PARTUUID,PARTTYPENAME= first — +the partition GUID above is the authoritative identifier, not the device name, +which can enumerate differently. + +Then set the order so ZBM is reachable: + +#+begin_src bash +efibootmgr --bootorder 0001,2001,2002,2003 +#+end_src + +(The original order put USB first. Keep whichever you prefer; what matters is +that the ZBM entry exists and is in the list.) + +* Other entries (firmware-generated, recreate themselves) + +| Boot2001 | EFI USB Device | +|----------+----------------| +| Boot2002 | EFI DVD/CDROM | +|----------+----------------| +| Boot2003 | EFI Network | +|----------+----------------| + +These are stock firmware entries and come back on their own. Only Boot0001 +carries anything unique. diff --git a/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg Binary files differnew file mode 100644 index 0000000..40c71af --- /dev/null +++ b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg |
