Thorium was "closing and dying randomly" with no evidence anywhere.
Two independent structural causes, both now fixed:
1. Crashpad was never enabled -- no crash_reporting consent key and an
empty crash DB, so no minidump was ever written.
2. stderr from a dmenu/.desktop launch reaches no terminal and no
journal. The only Thorium lines ever seen in the journal came from a
systemd-launched instance, which is why they survived.
browser-preexec-wrapper.sh now gives every Chromium-family launch its own
log under $XDG_STATE_HOME/browser-crash-logs: a VRAM/RAM/kernel/driver
snapshot before start, the browser's stderr, and on exit the exit code,
decoded signal, and a SECOND VRAM snapshot. Those two snapshots plus the
signal are the discriminator between the live hypotheses -- a VRAM death
shows NVRM/NV_ERR_NO_MEMORY or near-full VRAM at exit, a stale-binary
death shows SIGSEGV with the GPU idle. Root cause is deliberately NOT
asserted here; the next crash decides it from evidence.
Three details that are load-bearing rather than incidental:
- The exit record is written from a signal trap, not inline after
`wait`. If the wrapper itself is killed, an inline block never runs
and the log ends mid-stream with no verdict -- indistinguishable
from a browser that is still running.
- The browser is a direct redirect, NOT a pipe into a size filter: in
a pipeline `wait` reports the last element's status, discarding the
browser's signal, which is the one fact worth capturing.
- Logs are capped by bytes as well as count. Measured: ~2 MB per 45s
of navigation (65% VERBOSE1 net/TLS chatter), ~0 while idle. Count
alone would not bound disk use, and a full disk causes exactly the
random deaths under investigation.
setup_periodic_system.sh: rewrite Chromium "app mode" .desktop entries
(Exec=/opt/<browser>/...) to go through /usr/local/bin. These are
auto-generated and bypass the wrapper entirely, so neither the flags file
nor the logging applied to them -- five Thorium entries were found still
launching without the --disable-gpu-compositing mitigation added on
2026-07-25, two weeks after the fact. Runs on every install because
Chromium regenerates the files.
pacman_whitelist.txt: allow the exact package name `librewolf`. The
`librewolf` keyword stays blocked, as do all other Firefox forks. It is
LeechBlock-enforced via /etc/librewolf/policies (force_installed), so
this is not an unfiltered escape hatch; it is needed because Chromium's
MV2 sunset ends full uBlock Origin with all filter lists, which the Gecko
build keeps.
Verified: shellcheck clean, no lint suppressions; fake-browser SIGSEGV
and wrapper-SIGTERM both produce a verdict; trim watchdog bounds a
runaway log; kill -SEGV on the real /opt/thorium-browser/thorium wrote
"exit code: 139 / died on signal: 11 (SEGV)"; librewolf installs and
loads with uBO + LeechBlock active.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME5ZkgWjFvPZrUwMpVZM3e
The previous commit's pruning did not actually take effect. Per enginelib,
"disabled" only sets the DEFAULT for a fresh visitor -- "will allow the user
to manually activate it in the settings" -- so any browser session with an
existing preferences cookie kept running every pruned engine. yandex images
was still throwing JSONDecodeErrors in the log minutes after being
"disabled". "inactive" is the key that actually removes an engine.
Converted 84 entries. Verified behaviourally rather than by reading config:
an images search now serves from google cse images ALONE (20 results,
unresponsive=[]), and no pruned engine appears in the logs after a restart.
brave and qwant deliberately keep "disabled". Sibling engines reference
their outgoing connection pool by name (network: brave <- brave.images/
.videos/.news; network: qwant <- qwant news/images/videos), and "inactive"
removes the definition entirely, so the container dies at startup with
KeyError: 'qwant'. "disabled" keeps the pool defined while hiding the engine.
Pool owners in this image: brave, qwant, yandex, piped, yacy.
Also fixes two status checks that could report a healthy instance as broken.
Public /search was returning 429 while loopback returned 200, which looked
like a regression but was a missing Accept-Language header in the request:
botdetection runs one check per header and rejects on the first failure.
Setting "debug: true" under general: makes SearXNG log the exact rejecting
method ("NOT OK (searx.botdetection.http_accept_language)"), which settled it
in one run after several wrong theories. wait_for_site and check_headers had
the same omission and are fixed; the full required header set is documented
at check_http.
Public path confirmed working end to end: general 200 with 21 results in
0.71s, images 200 with 40 proxied thumbnails in 0.58s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG
kuhy went through the /preferences engine table and selected every erroring
engine (CAPTCHA / access denied / timeout / parsing error / crash) plus the
slow tail, across all nine categories. Applied all of them, keeping only
google cse images for the images category as requested.
Result: unresponsive_engines is now [] in EVERY category. Per-category
latency (q=linux): general 572ms, images 406ms, videos 1378ms, news 455ms,
music 461ms, it 1698ms, science 1338ms, files 584ms, social media 1074ms.
Images went 2.1s -> 406-900ms across 12 queries, still 20 results. The root
cause was not google cse images being slow (1.1s): SearXNG waits for the
SLOWEST engine in a category, so quark images at 2.9s set the floor. Cutting
the others removed the ceiling.
Three engines that were not in the list surfaced as unresponsive only under
load and were pruned with the rest: brave.images, startpage images, kickass.
Corrects a wrong claim from the first pass. I had said general search retained
three working sources because !go returned 20 results and !bi returned 10.
Upstream marks both google and bing "inactive: true", so they answer ONLY to
an explicit bang and were never part of the merged view -- which google cse
has been serving alone all along. The pruning therefore removed only engines
already contributing zero, but general search does have a single point of
failure, so status now prints the !go/!bi fallback when a live search returns
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG
Deploys a SearXNG metasearch instance behind the existing shared Caddy edge.
Graded A+ (score 120, 10/10) by the MDN Observatory API, and "Vanilla" in the
sense that every change is settings-only -- no template or static-file edits.
Public but deliberately not submitted to the searx.space directory.
Both containers run network_mode: host because Docker bridge networking has
zero egress on this box (nftables' default-drop forward chain is terminal at
NF_INET_FORWARD regardless of Docker's legacy-iptables ACCEPTs), so a stock
bridge compose cannot reach a single search engine.
Non-obvious things established empirically against the pinned image, all of
which cost a debugging cycle and are commented at their use sites:
- granian binds :: (all interfaces) by default, NOT the 127.0.0.1 its own docs
claim; with host networking that would have LAN-exposed the instance.
GRANIAN_HOST/PORT are the only things pinning it. settings.yml's
server.bind_address is inert (read only under __main__).
- Caddy's `> Header "v"` is a substring-replace, not a header setter -- it
silently set nothing. The bare name is "set"; `+` appends and would duplicate
the headers SearXNG already sends (X-Content-Type-Options, Referrer-Policy).
- server.public_instance force-enables link_token, a browser-proof challenge no
scripted client can pass, which makes the JSON API unusable. Dropped it and
kept limiter/image_proxy via their own keys.
- Without limiter.toml there are no trusted_proxies, so botdetection 429s every
request including the homepage.
- The entrypoint chowns config to uid 977 and needs DAC_OVERRIDE to read it, so
cap_drop breaks startup and mode 600 breaks the worker's read (640 is right).
- `compose up -d` is a no-op when only a bind-mounted config file changed, so an
explicit restart is required or every check tests the previous config.
- All curl checks must send browser-shaped headers; bare curl gets 429, which
had the status command reporting a healthy instance as broken.
Engine pruning is measured, not guessed: brave/duckduckgo/startpage CAPTCHA on
100% of queries, flickr is the 1.0s tail, and pinterest was failing because
this host's own /etc/hosts blackholes it -- a local cause that would otherwise
have been misdiagnosed as a dead upstream.
Measured latency, reported honestly: front page median 19ms, but search median
486ms (233-623ms). Sub-200ms search is not achievable by any configuration --
a metasearch cannot return faster than the slowest upstream it queries. The
~200ms figure on searx.space measures initial front-page response, which is met.
Image pinned by digest: the workarounds above are only known-true for 2026.8.4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG
Measured on the Pixel 6a, twice: with private_dns_mode=hostname and the
resolver unreachable, `ping example.com` returns "unknown host". Android's
Private DNS does not fall back to the carrier's resolver -- hostname mode is
RFC 8310's strict profile, and failing to establish TLS on 853 is a hard error.
So a resolver living on this PC means "PC off" equals "phone has no internet",
not merely "no filtering". That is disqualifying for a daily driver, and it
disqualifies the whole home-hosted delivery path regardless of how well it
works while the machine is up -- which it does; it was verified end to end.
DNS_TRUSTED_DOT_HOST/IPS go back to empty, which restores the previous enforcer
behaviour byte for byte. No code revert is needed, which is exactly why the
flag was built to default empty.
Two things found while testing are worth keeping. The nftables input chain is
policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel
address was not sufficient on its own: ICMP passed while TCP 853 was dropped,
which presents confusingly as "ping works but the port does not". That rule is
now added explicitly and scoped to wg0. And setup_phone_wireguard.sh automates
the client setup the tunnel needed, verifying handshake, reachability and DNS
rather than assuming success, and deleting the pushed private key afterwards.
The deeper reason the home endpoint could not work even with the tunnel up:
dns.kuhy.duckdns.org resolves to the public IP while stunnel listens only on
10.8.0.1, and with AllowedIPs=10.8.0.0/24 that routes outside the tunnel to a
closed port. Android never opened a connection at all. Split-horizon DNS does
not fix it either, since on mobile data the phone uses the carrier's resolver.
Research also ruled out the hosted-hybrid option that looked most attractive:
no provider can ingest a 185k-domain list (NextDNS none, AdGuard 1-5K, ControlD
10K), and the free tiers fail OPEN -- they stop filtering past 300k queries a
month while still resolving, which for a self-control tool is the worst failure
mode available. Details and the remaining options are in the parked doc.
The stunnel endpoint is left running deliberately: WireGuard-only, verified
unreachable from LAN and WAN, and it makes the next attempt testable
immediately. Removal commands are documented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
Two gaps stopped the unrooted path working end to end. The phone had no way to
read the focus policy, and the blocklist was only reachable as a hosts file or
over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected
every DoT connection, so a self-hosted resolver could not have worked even if
one existed.
focus_owner now loads a generated policy.json instead of hardcoding anything,
so config.sh stays the single source of truth across Python, shell and Kotlin.
The committed asset is rendered with --redact-home and a test fails if home
coordinates ever appear in it. An unknown schema_version is rejected outright:
silently accepting a newer schema means enforcing a policy the app has misread,
and a misread allowlist is one that blocks the dialer.
dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every
20 seconds. That is right when DoT is a bypass channel and exactly wrong when
DoT is the enforcement channel, which is what an unrooted phone needs -- it has
no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the
trusted resolver before the blanket REJECT, and pins Private DNS to that
hostname so the user cannot pick an unfiltered one. Rule order is load-bearing:
iptables takes the first match, so an ACCEPT appended after the REJECT would
never be reached. expected_rule_count() counts the new rules, because a
mismatch there makes chain_intact() rebuild the chain every 20s -- a silent
fork storm that also drops the connection each time. With the variable empty
the behaviour is byte-identical, so the rooted phone is untouched until this is
deployed.
setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a
raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would
have meant an xcaddy rebuild of a container serving five live sites. It reuses
the certificate Caddy already renews, so nothing competes for port 80. It binds
to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open
resolver, and over the existing tunnel it needs no port-forward, no firewall
rule and no rate limiting. Verified: example.com resolves, pornhub.com returns
0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses
refuse.
The Device Owner checklist is planning only. Checking the device rather than
the config settled the one open risk: Google Wallet is not installed and no NFC
payment default is set, so the unconfirmable tap-to-pay question does not apply
here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO
are absent -- they are in config.sh's whitelist, which describes the rooted
phone, not this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.
python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.
blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.
strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.
focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.
Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
23fdfcf pinned shellcheck because a version split between CI and developer
machines kept the workflow red for a day. The same trap was still open for
every other `language: system` tool, so close it for all of them.
The risk is not identical across the four, and the code says which is which:
shellcheck, zsh run in CI. Drift shows up as a CI failure the developer
cannot reproduce -- the b83ce5d case exactly.
prettier, shfmt never run in CI (pre-push and local git hook). They
REWRITE files, so two developers on different versions
reformat the repo back and forth.
check_shellcheck_version becomes a table-driven check_pinned_tool_versions
over PINNED_TOOL_VERSIONS, and _tool_version normalises the four different
formats these tools report (shellcheck's `version:` line, shfmt's leading v,
zsh's second field). zsh is compared on major.minor only: apt cannot select a
patch release on the runner image, so 5.9.2 vs 5.9 is not a real divergence
and must not become a permanent warning.
CI gains a step installing prettier at an exact npm version and shfmt from a
checksum-verified release binary, each asserting what PATH actually resolves
to afterwards. Those two are unexercised by the current job by design -- the
pin records the expected version in one place and protects any future
workflow that does run them.
Verified: YAML parses with all pins ahead of the hook step; the shfmt install
ran verbatim with `sha256sum --check --strict` reporting OK and the version
gate passing; prettier@3.9.5 resolves from the registry; the drift warning
fires per-tool against stubbed prettier 3.0.0 and shfmt v3.0.0 (confirming v
normalisation and that it reports every drifted tool, not just the first) and
stays silent against the real toolchain; common.sh is shellcheck clean after
rewording a comment that began "# shellcheck, zsh --" and was being parsed
as a malformed directive; 1193 Python tests at 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7
Root cause behind the outage 9dd9aeb cleaned up. The workflow never installed
shellcheck: it inherited whatever ubuntu-latest shipped (0.9.0), while
developer machines get 0.11.0 from pacman. The hook is `language: system`, so
each side linted with a different binary, and the two disagree -- 0.11.0
demoted SC2002 and several SC2015/SC2119 cases as noise. A commit could pass
every local gate and then fail CI on findings that could not be reproduced
locally. That is what kept the workflow red from b83ce5d onward, and why it
took fetching shellcheck 0.9.0 by hand to see the 11 findings at all.
CI now downloads shellcheck v0.11.0 from the official release, verifies it
against a recorded sha256, installs it, and asserts the version that PATH
actually resolves to. That last check matters: without it a stale copy earlier
on PATH would silently win and reproduce the same class of confusion.
The local hooks gain a matching EXPECTED_SHELLCHECK_VERSION and warn when the
installed version differs, naming both files to update. Warn, not abort --
shellcheck comes from pacman on a rolling distro, so a bump is routine and
should not block a commit; what must not happen is the disagreement going
unnoticed.
Verified: workflow YAML parses with the pin ahead of the hook step; the
install steps run verbatim locally with the checksum reporting OK and the
version gate passing; the drift warning was observed firing against a stubbed
0.9.0 and staying silent against the real 0.11.0; common.sh is shellcheck
clean; 1193 Python tests at 100% coverage. Pre-commit checks on 9dd9aeb went
green -- this change protects that rather than producing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7
"Pre-commit checks" has failed on every push since b83ce5d removed
shellcheck's --severity filter. The findings never appeared locally: CI
installs an older shellcheck than this machine, and 0.11.0 no longer emits
SC2002/SC2015/SC2119 at all. Reproduced by fetching shellcheck 0.9.0, which
matches CI's output exactly, and verified against both versions.
SC2015 x6 -- rewrote `A && B || C` as an explicit if. Each keeps its trailing
`|| true`, so a failing side effect still cannot abort the caller under set -e.
The first draft dropped it in run_phone.sh; a review pass caught that and it
was restored, since that call is the last statement of a function.
SC2119 x3 -- require_imagemagick and adb_select_device take an OPTIONAL first
argument, so the bare calls were already correct. Following shellcheck's
literal advice and passing "$@" would forward the script's own arguments as a
tool preference, which would be a real defect. Passing an explicit "" states
the same intent without changing behaviour, confirmed by running both forms.
SC2002 x2 -- `cat FILE | tr ...` becomes `tr ... < FILE`, keeping 2>/dev/null
so a missing file still yields an empty string.
No suppressions, no --severity filter, no exclusions added. Verified: 0
findings under both shellcheck versions, `pre-commit run shellcheck
--all-files` passes, test_adb_common 13/13, test_boot_repair 31/31, 1193
Python tests at 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7
Prep for a Ulefone Armor X12 Pro (MediaTek MT6765) that has not arrived,
validated read-only against the Pixel 6a that has.
Partition discovery never hardcodes boot vs init_boot. It reads
/dev/block/by-name/, derives the slot scheme from observed _a suffixes, and
picks the ramdisk carrier by preference order init_boot then boot over what is
actually present. The AOSP launch-version rule explains why that ordering is
right; the code consults the filesystem, not the API level. One path therefore
serves both a Pixel (no init_boot, falls to boot_a) and a 13-launch MediaTek
device (init_boot_a taken). Patching the wrong one yields a phone that will
not boot.
Classification refuses rather than guesses: ULEFONE needs a positive MediaTek
signal AND a vendor match, with no "not a Pixel therefore Ulefone" fallback.
Carrier lock is always reported UNDETERMINED, because sys.oem_unlock_allowed,
ro.oem_unlock_supported and settings global oem_unlock_allowed read identically
whether a device is carrier-locked or merely has the toggle off; asserting a
negative would wrongly rule out a path.
No flashing script, deliberately. Automating irreversible partition writes
defeats the confirmation steps that catch a wrong partition or missing slot
suffix.
Three bugs found by running the code rather than only linting it:
- SIGPIPE: `adb version | head -1` under pipefail exited 141 and killed
00-preflight.sh mid-run
- 20-dump-stock.sh exited silently when no recon had run, because find on a
nonexistent cache dir fails under set -e
- the read-only static scan had a hole: stripping all quoted strings to avoid
false positives on printf text also hid `adb shell "reboot bootloader"`.
Now only printf/echo/cat arguments are stripped, and a negative self-test
asserts the scan fails on injected commands
Also narrowed a world-writable udev catch-all (idVendor=="*", MODE="0666")
that granted every local process read/write access to every USB device;
commented with a backup, adb connectivity re-verified afterwards.
Validated: 58/58 offline fixture tests; 00-preflight.sh HOST READY (12/12);
10-recon.sh read-only against the Pixel reports PIXEL, boot_a, A/B, no
init_boot, path C-or-D indeterminate, with the device unchanged afterwards.
Untested and labelled as such in READY.md: the init_boot branch on real
hardware, mtkclient over BROM, and the whole flash/verify sequence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7
The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:
- Five scripts in single_use/misc/testsAndMisc-bash/ sourced
$SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
at that path, so all five failed at runtime.
- setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
(which ends in `exit 0`) and then `exit 1` for an unknown command. The
exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
- Six i3blocks scripts had a `# shellcheck source=` directive missing the
scripts/periodic_background/ path segment. The stale path had silently
disabled source-following for all of them.
- diagnose_arch_performance.sh word-split a newline-joined file list; when
the find came back empty, awk got no file operands, fell through to
reading stdin, and the diagnostic hung.
- lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
`return 0` while documenting "Returns: 0 if handled" -- a contract no code
path could satisfy.
- deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
level, and config.sh can repoint it at the on-device path
/data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.
The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.
17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.
Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.
The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.
third_party/ is excluded -- vendored upstream, matching the prettier hook.
Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pacman intermittently freezes at its FIRST PreTransaction hook - 5 abandoned
transactions since 2026-07-29, stalls of 4 minutes to overnight, always fine on
retry. This investigates it and repairs the real defects found on the way.
The stall is NOT fixed, and deliberately so: no verified defect was found, so
nothing pacman-side was changed.
- Not guard-lib. /etc/guard-lib/targets was empty, so that hook script
provably exits in milliseconds. The failures share POSITION (first hook of
the transaction), not identity: one stall hit the hosts hook back when that
one ran first.
- Not reproducible on demand. 65 real transactions (40 idle, 25 under 2.6 GB
of memory pressure) produced zero stalls. Against the historical 4-in-56
rate that is ~0.9% likely, so the trigger is conditional on something a
synthetic load does not recreate. Memory pressure is untested at the load
that actually occurred - not refuted.
So diagnose_pacman_hook_stall.sh drives real transactions with a watchdog, and
its --watch mode passively captures /proc/<pid>/{stack,wchan,syscall}, the
process tree, meminfo and PSI from the next REAL stall. No pacman-side fix
should ship before that dump exists.
Three real defects were found and fixed:
1. The deployed pacman wrapper was 19916 B against a 27510 B source, never
sourcing the shared pacman_lock_lib.sh that makepkg_wrapper already used.
It hid because check_pacman_wrapper() only tested that files EXIST. Both
installers now write a drift manifest covering source AND installed hashes;
the checker replays it with sha256sum -c and fails closed when it cannot
verify. Manifest paths are absolute - relative ones resolve to nothing under
systemd's cwd=/ and would have looped reinstalling forever.
2. The guard-lib migration (66c4698) was never finished here: guard-lib was
installed with zero registered targets while the legacy hosts-guard layer did
all the work, so every transaction ran two PreTransaction hooks and the first
one did nothing. migrate_hosts_guard_to_guard_lib.sh registers hosts,
nsswitch and resolved, retires the legacy hooks and units, and has a
--rollback that was actually exercised. Plugins install to a stable path
because guardctl records absolute plugin paths. Side effect: one hook pair
instead of two, transactions ~5-6s -> ~2-3s.
3. "One heavy job at a time" was a rule a human had to remember. heavy_job_lock.sh
makes the overlap impossible: one system-wide flock shared between root and
the user (a per-user lock would give root's pacman and the user's builds
separate locks and no exclusion at all). Wired into pacman TRANSACTIONS only
- read-only queries stay instant - plus makepkg_capped and the coverage hook.
Always fails open.
Two bugs surfaced only by running --rollback, both of which briefly left
/etc/hosts with no immutable flag: fg_uninstall clears +i on its way out while
legacy watchers only fire on PathModified, and `((i > 20)) && break` left the
loop body's status at 1, which under set -e aborted the rollback silently - but
only when the loop actually ran, so --dry-run looked healthy.
Not migrated: the shutdown-schedule guard (/etc/shutdown-schedule.conf does not
exist here). Not wired: Flutter/Gradle builds, which have no single entry point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the repo to prettier-clean across all 525 tracked md/json/yaml files.
The markdown edits are cosmetic: escaping `L*` so it stops rendering as
emphasis, normalising `*em*` to `_em_`, and aligning tables.
billsplit/android/gradle.properties shipped from the Flutter template asking
for `-Xmx8G -XX:MaxMetaspaceSize=4G` — more than this machine has. Lowered to
768m/512m and, more importantly, cut the number of JVMs: Kotlin compiles
in-process, no persistent daemon, no parallel execution, one worker.
Also repairs a regression an earlier prettier pass introduced. That pass
pretty-printed test/fixtures/sample_receipt.json, but
`accepts bare JPK payload and bare JWS token` reads the fixture as raw TEXT
with a regex matching `"data":"..."` with no space after the colon. The
inserted space made the match null and the null-check operator threw.
Comparing the parsed JSON for equality had passed and proved nothing about
that raw-text contract.
The fixture is restored to its original minified bytes — a real e-paragon
arrives as single-line JSON — and test/fixtures/ is now in .prettierignore so
captured payloads stay byte-faithful. The regex also tolerates whitespace now
and asserts the match is non-null with a reason, so the same change would fail
readably instead of crashing. Verified against both the minified and the
pretty-printed fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A completely absent `prettier` binary went unnoticed long enough for
unformatted markdown to reach main. Two independent causes, both fixed here.
No pre-push hook existed. The three hooks declaring `stages: [pre-push]` in
.pre-commit-config.yaml — prettier, ci-mirror, pytest-coverage — therefore
never ran locally at all. Adds one.
The pre-commit hook failed open. shfmt and shellcheck were wrapped in
`if command -v`, and the entire pre-commit framework was skipped with a
warning when its binary was missing. A silently skipped gate is
indistinguishable from a passing one, so a missing tool now aborts.
- lib/common.sh: require_tool tries `sudo -n pacman -S` and aborts with the
exact command when a tool cannot be installed non-interactively;
verify_toolchain applies it to all eight binaries the hooks invoke.
- The pre-push hook resolves git's stdin refs into --from-ref/--to-ref.
Without a range, `pre-commit run --hook-stage pre-push` checks *staged*
files, and nothing is staged at push time, so prettier would have received
zero files and passed vacuously.
- ensure_hooks_path repairs core.hooksPath drift on every hook run.
- meta/scripts/install_hooks.sh covers the one case the hooks cannot
self-heal — a fresh clone where the config was never set, so nothing runs.
`--check` reports state and exits non-zero when incomplete.
Note `pre-commit install` must never be used here: it writes into
.git/hooks/, which git ignores entirely while core.hooksPath is set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On 2026-07-30 a kernel upgrade left the machine in systemd emergency mode
on three consecutive boots. The upgrade replaced /usr/lib/modules/<old>
with <new>, but the new kernel never reached the ESP, so the firmware kept
booting the OLD vmlinuz whose module tree had just been deleted. Nothing
modular could load after pivot-root — including vfat, which meant /boot
itself could not be mounted, local-fs.target failed, and systemd dropped to
an emergency shell. pacman reported the transaction as successful.
Root cause: pacman-git with ParallelDownloads > 1 segfaults every forked
alpm hook child, silently skipping 90-mkinitcpio-install and depmod.
boot-repair is self-contained (no repo libs — it must run in the emergency
shell) and fully offline (missing modules are recovered from
/var/cache/pacman/pkg). It repairs by default; --dry-run inspects.
It detects and fixes: read-only root, noauto/nofail fstab drift, orphaned
kernel files shadowing an unmounted ESP, unloadable vfat, unmounted ESP,
missing modules.dep, ESP kernel != installed tree, stale initramfs, and
ParallelDownloads > 1.
Two pacman hooks make the failure impossible rather than merely
recoverable: 05-boot-mounted-guard aborts a kernel transaction when the
ESP is unmounted (AbortOnFail), and 99-boot-autorepair fixes ESP drift
after the transaction, before the machine is ever rebooted.
Safety: a module tree counts as usable only if it has BOTH vmlinuz and
kernel/, so the metadata-only wreckage this bug leaves — which sorts
newer — is never chosen as the target. Files under /boot are deleted only
while it is not a mountpoint AND contains no EFI/ or loader/; deleting
after mounting would destroy the real kernel.
Verified:
- 31/31 fixture tests (--root against throwaway trees, no privileges)
- 11/11 reversible live tests on the real machine: unmounted the real ESP,
planted shadow files, confirmed they were deleted and the ESP then
mounted with EFI/, loader/ and kernel 7.1.5-arch1-2 intact
- Gate proven against a real transaction with the ESP unmounted:
"failed to commit transaction", no packages upgraded, nothing written
- shellcheck + shfmt clean, pre-commit passing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate caught this: test_session_autopsy_hooks reads the DEPLOYED hook under
~/.claude, so repointing that hook to kuhyx/mcp-servers broke an assertion still
expecting `python_pkg.session_autopsy`. Exactly what a deployment-path test is
for — a test against a stale in-repo copy would have stayed green.
The replacement is stronger than what it replaces: it asserts both the new module
path AND that "python_pkg" is absent, so regressing to the old location fails
rather than passing quietly. The mutation checker confirms all 12 tests are still
killed by at least one mutation.
Also adds the evidence/contract artifacts for the extraction. They were written
for 627b9e7 but never committed: that commit was pure deletions, and the
ai-evidence-contract hook does not treat deletions as code changes, so it passed
without them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.
Deployment
dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
with --delete-after --delay-updates; plain --delete removes stale assets
before writing the new ones, which 404s a client mid-load. A post-commit hook
starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
commit nor thrash zram.
Three traps found by driving the live URL in a browser rather than curling it:
a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
break ACME for every domain here. The same address also made a Host matcher,
so the edge's forwarded Host matched nothing and served an empty 200 while the
local health check passed. And watching dist/'s own inode could never detect a
detached bind mount: rsync into an existing directory never changes it. Each
now has a guard that asserts positively rather than failing open.
Dice list
All 43 on screen at every width, no inner scrollbar. Column count and size
tier are chosen together from a container query on the panel -- not the
viewport, which mispredicts by 2x at the point the columns split. Two
invariants govern it: the list fits above the fold, and the name column stays
wide enough to tell Painter's die B, G and R apart. The left column holds the
grid alone, so it is sized to fill the height it owns: one --die-row value
drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
a 1296px-tall screen and 20px rows on a phone.
Three safe-design rules are broken inside .die-list deliberately: sub-16px
body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
a chart and a counter in one viewport is arithmetically incompatible with 16px.
Import / export
A file, the clipboard, or a link carrying the inventory in the URL fragment.
One validator behind all four entry points including localStorage; it keeps
what it can and reports what it dropped, so a die added in a later game patch
costs you that entry, not the inventory. A link never silently overwrites what
is saved here -- it offers Keep or Discard, and only a link that actually
carries dice is applied at all, because one encoding a count of zero parsed
cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
over the visitor's own loadout.
session_autopsy
parse.py (551) and report.py (504) were over the repo's 500-line limit, so
test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
and the two candidate-cell helpers. Report output is byte-identical on real
data apart from its timestamp, checked against HEAD in a worktree.
Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`git commit`/`git push` export GIT_DIR to every hook they run, and the
flutter tool shells out to git to identify its own SDK. `git -C
"$FLUTTER_ROOT" rev-parse HEAD` in bin/internal/shared.sh does not beat an
inherited GIT_DIR -- `-C` only changes directory, GIT_DIR overrides
repository discovery outright:
$ git -C /opt/flutter rev-parse HEAD
058e0af2c2... <- Flutter's HEAD
$ GIT_DIR=~/diet-guard/.git git -C /opt/flutter rev-parse HEAD
84bd550331... <- diet-guard's HEAD
So inside a hook, flutter reports the project as the SDK:
Flutter 3.47.0-0.1.pre • channel main • https://github.com/kuhyx/diet-guard
pub then resolves the SDK as 0.0.0-unknown and every version constraint
becomes unsatisfiable. flutter also writes that into its own version cache,
so the wrong answer outlives the hook and follows the next caller into an
unrelated repository -- which is why the identical command passes when you
re-run it by hand and fails again on the next push.
diet-guard's pre-push gate hit this today. Clearing the variables per call in
each hook works, and screen-locker and diet-guard now do, but only for as
long as every future hook author remembers -- deployment hygiene, not a fix.
This installs a wrapper ahead of /usr/bin on PATH that strips them for every
caller in every repo, covering both flutter and dart. It removes its own
directory from PATH before exec so the Flutter entry point's internal `which
flutter` cannot loop back into it, and it verifies itself by running flutter
against a throwaway poisoned GIT_DIR and failing unless the real SDK comes
back.
Not patching bin/internal/shared.sh: that file belongs to the flutter-bin
package, so the next update would silently restore the bug. Upstream already
unsets these variables in content_aware_hash.sh and update_engine_version.sh,
just not in the version path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The i3 `exec` line pointed at
/home/kuchy/linux-configuration/scripts/utils/turn_off_auto_idle_screen_shutdown.sh
- a typo'd home ("kuchy") *and* a path that stopped existing when the repo
was reorganised in db6276b. The exec has been failing silently at every
login ever since, so the idle inhibitor was simply not running: a controller
was connected with no "idle-off" lock held, meaning the screen could blank
and lock during controller gaming, which is exactly what the line exists to
prevent.
Point it at the script's real location.
Also make --watch-controller a singleton. It is a long-lived watcher started
from an i3 `exec`, and i3 re-runs those on every `i3-msg restart`, so each
restart would leave another watcher holding a redundant inhibitor for the
rest of the session. One flock, held for the life of the watcher.
Verified: the watcher now holds
idle-off 1000 kuhy <pid> systemd-inhibit idle "game controller connected"
and a second invocation declines instead of stacking a second inhibitor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ⚙ statusline badge counted all 182 candidates — meaningless. It now
counts candidates ≥100k est weekly tokens (7 today): a number small enough
to mean 'worth your time'. token-savers.zsh gives the compiled scripts
shell entry points (finish-gate / phone-deploy / autopsy / yay-clean) so
the deterministic halves cost zero LLM tokens. Adds the first explanation
doc produced by the newly compiled explain-diff scaffold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a83b5ab took the analyzer package to 100% branch coverage but stopped at the
package boundary. Its only production entry points are two shell hooks in
~/.claude/hooks, and those were untracked, unlinted and untested -- the same
"untracked meant unlinted" shape that commit's own message describes, one
directory over. Both run on every session on this machine.
Both hooks end in an unconditional exit 0 by design: an autopsy failure must
never surface into the session lifecycle. That makes exit status a constant, so
a test asserting it passes against a hook whose body has been deleted. Every
assertion here is on a side effect instead. For SessionEnd a setsid shim on PATH
records the launch argv rather than spawning an ingest, so the tests can pin the
command, transcript path, --quiet, PYTHONPATH and timeout on the happy path, and
pin no-launch-at-all for a missing key, an empty path, a nonexistent file and
malformed JSON.
SessionStart asserts stderr as well as stdout. Under set -u, (( count > 0 )) on
a non-numeric count raises an arithmetic error to stderr while stdout stays
silent, so a stdout-only assertion would miss a hook injecting bash noise into
every session start. That gap was found by mutation, not by inspection.
A pytest file rather than a test_*.sh, deliberately: only .py under
linux_configuration/tests is collected by pytest_changed_packages.py, so this
lands in the local pre-commit gate, whereas the .sh tests there are gated by the
shell-tests workflow alone. It imports nothing from python_pkg, so the
100%-on-python_pkg gate is untouched -- 977 pass at 100% branch.
Verified by a per-guard mutation matrix rather than one whole-body gutting:
gutting alone kills only the three positive tests and would have falsely branded
the nine negative ones vacuous. Dropping each guard in turn, widening > 0 to
>= 0, and announcing unconditionally kills all twelve. The matrix also showed
that the start hook's [[ ! -r "$STATE" ]] guard is redundant -- deleting it kills
nothing, since jq's stderr is suppressed and an empty count already fails the
arithmetic. Left in place as defence in depth; the hooks are unmodified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caps CPU (amd-pstate-epp) and GPU (nvidia-smi power limit) during the
existing night-lockdown window instead of migrating services to a
Raspberry Pi (tried, abandoned after SSH auth failed). A more aggressive
GPU PCI-unbind option is built but left disabled by default.
Also fixes an unrelated live incident found while testing: dnsmasq (this
PC's LAN DHCP/DNS server) had died with zero error logged, breaking WiFi
for every device on the network. Added a watchdog timer mirroring the
existing shutdown-timer-monitor-watchdog pattern, and whitelisted the
Tapo smart plug app so the overnight power reading is checkable during
the phone's curfew window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The unlock script started morning-routine.service on every staggered trigger
(05:00/05:15/05:30/06:00/07:00) on alarm days. That was a workaround for the
always-on lockdown removing the hibernate/resume event the alarm used to ride
on — reasonable when nothing else could fire it.
Something else fires it now. wake-alarm-trigger.timer ticks every minute and
starts morning-routine.service at the time the phone synced, with a catch-up
window for a suspended or just-booted machine. Keeping both meant two
independent triggers, one of which ignores the phone entirely.
That is not hypothetical: on 2026-07-20 the phone was set to 08:00 and the
alarm went off at 07:00, started here, with no wake-alarm-trigger "Firing" line
in the journal at all. The 08:00 fire then landed as a no-op because this
07:00 run had left morning-routine.service still active.
Drop maybe_start_morning_alarm() and both call sites, plus the config that
went dead with it (ALARM_DAYS_DOW, MORNING_ROUTINE_UNIT, the alarm_started_date
marker). MIN_WAKE_AFTER_LOCKDOWN_HOURS stays — the wake-floor trigger uses it.
The lockdown's actual job — console/GUI restore, unmute, unmasking the login
surface — is untouched.
The only starters of morning-routine.service left are wake-alarm-trigger.timer
(primary) and the system-sleep hook (resume backstop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test 10 only knew the retired shutdown-schedule-guard.path, so it
reported a skip while guard-file@shutdown-schedule.path was actively
protecting /etc/shutdown-schedule.conf. That understated the real
protection in the same way tests 2, 3 and 5 did before the last
commit.
It now accepts the guard-lib unit and passes. The
disabled-is-intentional branch stays for hosts running neither unit.
Host: 19 passed / 0 failed / 5 skipped. Container: exit 0.
The suite reported four failures on this host and none of them meant
protection was missing.
Tests 2, 3 and 5 checked hosts-guard.path, hosts-bind-mount.service
and nsswitch-guard.path. The 2026-07-17 guard-lib refactor replaced
those with templated units — guard-file@hosts.path,
guard-bind-mount@hosts.service, guard-file@nsswitch.path — which are
all active. That is also why the legacy units stop a second after
boot: they are superseded, and the old nsswitch guard retriggered
itself into systemd's start limit fighting the new watcher. Each check
now accepts either generation, and the bind-mount check also accepts
the read-only mount on /etc/hosts, which is the real invariant.
Test 10 now treats a *disabled* shutdown-schedule-guard as intended
rather than broken: the poweroff flow it protected was replaced by
night-lockdown-enter.sh. An enabled-but-inactive unit still fails.
That check also had a shell bug — `systemctl is-enabled` prints the
state and exits non-zero when disabled, so `|| echo disabled`
appended a second line and never matched.
Verified protection is genuinely intact rather than assuming it:
/etc/hosts is mounted ro,noatime and both it and /etc/nsswitch.conf
carry the immutable flag. Host now reports 18/0/6. A container with
faked host markers still reports 10 failures, so the suite has not
been softened into always-green.
No system state was changed.
The last three exclusions are gone.
test_i3blocks_efficiency.sh only ever needed strace and ptrace. It went
unverified earlier because Docker's bridge network has no DNS on this
machine, so pacman could not install strace; --network=host works, and
the test passes. The job installs strace and adds SYS_PTRACE.
test_hosts_guard_pacman_integration.sh asserted three symbols the
guard-lib refactor renamed (pacman_hooks_manage_hosts_guard ->
pacman_hooks_manage_guard_lib, and similar). The feature was intact;
only the assertions were stale.
test_security_hardening.sh now separates live-host checks from
repository checks. Off-host the live ones skip, so it is green in a
container while still running its repository checks — and on the real
host it still fails loudly when a guard unit is down.
That last part needed care: the first detection attempt used
`systemctl is-system-running`, which exits non-zero on a degraded
system — precisely the state a failed guard unit produces. It masked
all four real failures. Detection now tests for /run/systemd/system
plus the installed guard payload, which unit health does not affect.
Verified: all 8 arch tests pass in a container; the same
security-hardening file still reports 4 failures on this host.
Adds an arch-tests job on archlinux:base-devel so the pacman wrapper,
makepkg wrapper and hosts-guard tests finally run somewhere. They need
an Arch userland and root, which a disposable container provides.
Four more tests still referenced pre-reorganisation paths and could
never find their targets; repairing them took the container set from
two passing to five. All five were verified with --network=none, so
the job installs nothing beyond git (base-devel ships no git, and
actions/checkout needs it).
Also removes the silent-failure mode behind the earlier
music_parallelism confusion. The test now derives its temp-worktree
path from the script's real position instead of hardcoding the depth,
checks up front that ../../lib/common.sh resolves to the stub, and has
the stub write a marker asserted after every run. Previously a
reorganisation made the script source the real
/usr/local/lib/common.sh, so the test measured the live system and
failed as though the daemon were broken.
Three tests stay out, each for a different reason, documented in the
workflow: test_i3blocks_efficiency.sh needs strace and could not be
verified locally (containers here have no DNS);
test_hosts_guard_pacman_integration.sh fails a real assertion worth
investigating; test_security_hardening.sh asserts live host state and
can never pass in a fresh container.
Both tests excluded from CI last commit turned out to be stale rather
than catching regressions. Neither covered script needed changing.
test_music_parallelism.sh built its temp worktree at
scripts/digital_wellbeing/, but music_parallelism.sh resolves its
library as $SCRIPT_DIR/../../lib/common.sh — two levels up, correct
for the real scripts/periodic_background/digital_wellbeing/ location.
From the shallower path that resolved outside the worktree, so the
stubbed common.sh was never sourced and the script fell through to the
real /usr/local/lib/common.sh on the machine. The test was exercising
the live library and ignoring its own MOCK_FOCUS_ACTIVE, which is why
idle mode looked like it waited 15s instead of 30s. The daemon was
correct all along.
test_usage_monitoring_installer_efficiency.sh demanded a literal
'sleep 60'. The logger template no longer polls: it sleeps once until
midnight via seconds_until_next_day, then blocks on wait for the pmon
child — strictly fewer wakeups than the assertion required. Replaced
with assertions on that property so a return to polling still fails.
Allowlist extended from four tests to six.
The shell tests under linux_configuration/tests/ never ran anywhere:
no workflow executed them, so they rotted silently. Three referenced
script paths that moved under scripts/periodic_background/ in an
earlier reorganisation, and test_android_guardian_service.sh created
its temp worktree at a different path from the one it copied into, so
its cp could never succeed.
The new workflow runs the bats suites plus an explicit allowlist of
side-effect-free tests. Roughly eight others drive pacman, /etc,
systemd or adb and need root plus an Arch container, so they stay out.
Repairing the paths uncovered two genuine failures, deliberately left
out of the allowlist because they look like real regressions in the
scripts they cover rather than stale assertions:
- test_music_parallelism.sh expects idle mode to wait
IDLE_CHECK_INTERVAL (30s); the daemon waits 15s, so it wakes twice
as often as intended when idle.
- test_usage_monitoring_installer_efficiency.sh expects the logger
template to sleep between day-rollover checks.
Both need a fix in the covered scripts, not in the tests.
Diagnosing "Steam downloads are slow" kept pointing at the ISP because
every averaged measurement showed a steady ~170 Mbps. Sampling at 1s
instead of 5s showed that plateau does not exist: the link alternates
between ~450 Mbps bursts and multi-second stalls. Effective rate is
burst x duty_cycle, and only the second factor was wrong.
steam-download-duty.sh reports those two factors separately, which is
what the Steam UI cannot do. line-speed-probe.sh separates burst from
sustained throughput, because a single stream tops out near 180 Mbps on
this line and reads convincingly as ISP shaping when it is not — a
many-connection swarm reaches 986 Mbps with zero retransmits.
Arithmetic is extracted into pure helpers with main() behind a
BASH_SOURCE guard so the bats suite can source them without starting a
measurement. install.sh installs bats and links both scripts onto PATH.
Note: no CI workflow runs .sh/.bats tests, including the four shell
tests that predate this one, so the suite is manual-only for now.
Steam/browser mutual-exclusion daemon was unusable in its current state.
Removed all code, install tooling, and every reference across
install_core_system.sh, check_and_enable_services.sh, the security
hardening test suite, and CLAUDE.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diagnosed the "claude keeps crashing + mouse-command garble" as three stacked
bugs (remoteControlAtStartup end_session collision, SIGTTIN suspend on early
input, and an upstream REPL-exits-on-startup-input). This carries the shell-side
mitigations and the tooling that caught them:
- _claude_mouse_heal precmd hook: disables mouse tracking before every prompt,
so garble left by a *suspended* claude clears on the next Enter (the on-exit
reset in claude() can't fire while the function is blocked in a suspend).
- Always-on --debug-file logging in claude() (~/.claude/logs, keeps last 20;
opt out with CLAUDE_DEBUG_LOG_OFF=1) -- this captured all three root causes.
- On-demand launchers claude-rag/-travel/-sports/-media/-extras load the niche
MCP servers via --mcp-config, keeping them out of the always-on global fleet.
- claude-debug raw-binary probe (note: zsh printf has no %(...)T directive).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZ15AWWYN8xGKdwXKn7foM
The alarm floor (lockdown-entry + 8h) from the previous commit still relied
on the fixed 05:00/05:15/05:30/06:00/07:00 unlock triggers to ever re-check
it. Shutdown time is configurable (/etc/shutdown-schedule.conf, changed by
the screen_locker sick-day feature) and can vary night to night, so a late
enough lockdown (e.g. 23:30) pushes the floor past every remaining trigger
(07:30 > 07:00) and the alarm would silently never fire that morning.
Enter script now schedules a transient systemd-run --on-calendar one-shot
timer at exactly lockdown-entry + MIN_WAKE_AFTER_LOCKDOWN_HOURS, computed
from the real lockdown timestamp rather than assumed shutdown hours. It
re-runs the idempotent, self-dedup'd unlock script, guaranteeing the alarm
gets a correctly-timed chance regardless of what time lockdown started.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4YWZhDcEUMUi8cyw9AzTR
The 23:00 lockdown -> 05:00 unlock cycle exposed three live bugs: a visible
console-text flash before the screen went black (blank_console only ran once
systemctl stop lightdm.service fully returned, leaving a gap), DP-0 needing a
manual xrandr/replug after wake, and the morning alarm firing only 6h after a
late lockdown entry instead of waiting a full 8h.
- Blank the console continuously in a background poller for the whole GUI
teardown instead of a single after-the-fact call.
- Retry autorandr --change against a saved profile after lightdm restarts.
- Stamp lockdown-entry time and gate the alarm on entry + 8h, decoupled from
the console/GUI-restore idempotency so it can fire on a later staggered
trigger instead of only the first one.
- Add DRY_RUN NOTE banners so a clean dry run is never mistaken for proof the
console-blank and autorandr fixes work for real (they need a live cycle).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4YWZhDcEUMUi8cyw9AzTR
Typing during Claude Code's ~5s startup triggered a SIGTTIN job-control
stop ("suspended (tty input)") that left mouse tracking / terminal probes
enabled, spamming SGR mouse (35;44;41M) and DSR (997;1n) sequences.
Upstream bug, closed "not planned" (anthropics/claude-code #7807#11898#23581#50032); no flag/env disables the probes.
Wrapper runs claude with job-control monitoring off (nomonitor) so it
can't take SIGTTIN, and resets mouse/focus/paste/cursor modes to /dev/tty
on exit as a self-heal. Opt-out via CLAUDE_TTY_FIX_OFF=1. Auto-loaded via
an oh-my-zsh custom symlink (same pattern as calc-live.zsh); .zshrc
untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fwBQdNPJPgKR3gcDZLRjo
focus_mode_daemon.py has been 509 lines since 22e22c1, over the repo's
cap. It went unnoticed because the Python tests workflow only triggers on
python_pkg/** changes, so that commit never ran CI; the next commit to
touch python_pkg/ exposed it and turned main red.
Extract configuration, logging, notification, /proc process inspection and
the killers into _focus_mode_lib.py, leaving the daemon its policy: the
whitelist, the FocusMode state machine, and main(). log/notify move with
the process code that calls them, so the dependency runs one way and there
is no cycle. The helper is underscore-prefixed and registered in pylint's
init-hook path, mirroring the existing usage_report.py / _usage_report_*.py
sibling-import precedent.
The daemon runs from a symlink in /usr/local/bin, so a sibling import only
works if Python resolves that symlink before setting sys.path[0]; verified
that it does, and the installer symlinks rather than copies, so the sibling
travels with it. Confirmed against the running service: restarts clean and
resumes enforcing, process detection unchanged, status output matches the
pre-split version.
Structural only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
install_focus_mode_daemon.sh copied the script to /usr/local/bin, so the
deployed daemon went stale the instant the repo file was edited: what you
run stops matching what you read, with nothing to tell you. That is not
hypothetical - the zombie-detection fix in 22e22c1 sat in the repo while
a months-old copy at /usr/local/bin/focus-mode-daemon went on entering
GAMING mode and killing browsers. It only surfaced because the deployed
copy was checked by hand.
Link instead of copy, so editing the repo file IS deploying it. Also
chmod +x the source, since a symlink takes its executability from the
target rather than from an install mode.
uninstall now guards with [[ -e || -L ]] rather than [[ -f ]]: both -f
and -e follow the link and report false when the target is gone, which
is exactly the case (repo moved or deleted) where a stale link most
needs clearing.
The link resolves into the user's checkout, so /usr/local/bin/... is only
as trustworthy as that path. Fine here - focus-mode runs as a systemd
*user* service, crossing no privilege boundary. Not a pattern to copy for
anything root runs.
Verified: stat -L reports the same inode (92274745) for the deployed path
and the repo file; appending a marker to the repo file makes it appear at
/usr/local/bin/focus-mode-daemon immediately, with no reinstall; a link
to a missing target is now removed by uninstall where [[ -f ]] skipped it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rN2usTS3pYLJqeVg2ZpgR
Focus mode flapped in and out of GAMING mode every few seconds and killed
browsers while no Steam was running. The pattern matching was not at
fault: the daemon was correctly seeing a process named "steam". That
process was a root-owned zombie.
A total gaming block on 2026-07-04 purged the Steam client and it was
never reinstalled, but steam-backlog-enforcer kept trying to launch it
through an orphaned /usr/local/bin/steam wrapper still pointing at the
deleted /usr/bin/steam. Each launch died on exec and, never being reaped,
lingered as a dead process that still carried the name "steam" - which
this daemon counted as a live Steam.
get_running_processes() now reads /proc/*/stat rather than /proc/*/comm
and skips state Z. Both cost one read per PID, so the poll loop stays
fork-free, but stat also carries the process state. _parse_stat() uses
rpartition(')') because a process name can itself contain spaces and
')', which naive whitespace splitting would corrupt. Names are truncated
to 15 chars in stat exactly as in comm, so the existing patterns match
unchanged.
Verified against the live zombie, before the enforcer was fixed:
zombies on system now : ['steam']
OLD (/proc/*/comm) sees 'steam' : True
NEW (zombie-filtered) sees 'steam' : False
dropped by filter : ['steam']
and non-regression on real processes: a synthetic live 'steam' and a
'steam_app_29687' game both still return True. It skips zombies, not
Steam. Running `focus-mode-daemon` now reports "Browser detected -
entering BROWSING mode" with no GAMING flapping.
The zombie source is fixed separately in steam-backlog-enforcer d54df3a;
this is the durable guard against any future one.
Note: install_focus_mode_daemon.sh copies rather than symlinks, so
/usr/local/bin/focus-mode-daemon was a stale copy without this fix.
Reinstalled. Editing this file alone never changes what runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rN2usTS3pYLJqeVg2ZpgR
The digital-wellbeing curfew powered the PC off at 21:00, which also took
down every service this machine hosts (Gitea, the Caddy TLS edge, SyncYomi,
the personal site, Open WebUI, Joplin, dufs, ollama, dnsmasq, wg-quick,
sshd). Keep the curfew's lockout, drop the power-off: the machine now stays
on with the GUI torn down, the TTY login surface masked, the console blanked
and the RGB killed, and restores itself at 05:00.
The fortress still owns the schedule (immutable config, ratchet, monitor,
watchdog); only its terminal action is swapped for night-lockdown-enter.sh.
The reversal deliberately lives outside the fortress so a bug in the lock
path can never take the recovery path down with it.
Verified in production overnight: lightdm down 21:00:04 -> 05:00:00 while
all 7 containers stayed "Up 14 hours" and kept serving; ticks idempotent all
night; 05:00 unlock fired.
Notable fixes found along the way:
- Wake alarm regression: it only ever fired on resume-from-hibernate, so an
always-on machine silently killed it. The unlock now re-fires
morning-routine.service on alarm days (the Mon/Fri/Sat/Sun gate lived in
the removed rtcwake branch, not in the alarm).
- Morning boundary -le -> -lt, so 05:00 no longer races the unlock.
- Mask getty@ only; autovt@ is an alias of it and masking it errors.
- Persist LOCKED before the session-killing teardown, else a kill mid-lock
leaves state=UNLOCKED and the unlock no-ops, stranding the machine.
- Blank the console without DPMS: FB_BLANK_POWERDOWN drops the DisplayPort
link and the GPU never re-trains it (DP-0 needed a physical re-plug).
- RGB: openrgb-git 0.9.2148's CLI returns before detection finishes, so every
call silently no-op'd while exiting 0. Stable openrgb 1.0rc3 fixes it. Use
static+black, not "off" (the ZOTAC has no Off mode; the ASRock ignores it).
- Validate the generated config with bash -n: its heredoc must stay unquoted,
so a stray backtick executes and corrupts the file.
Thu-Sun 22->21 aligns the source with the canonical schedule screen_locker's
sick-day feature ratcheted in on 2026-07-03; the ratchet only permits
same/stricter, so the script was otherwise un-re-runnable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dpu8SqqBVm97tCuTGE3nCs
Three of the four remaining items were scripts hardcoding assumptions that
stopped being true — not real drift.
hosts (phantom warning): check_hosts looked for 10-unlock-etc-hosts.hook /
90-relock-etc-hosts.hook, gone since the guard migrated to guard-lib's generic
unlock-all/relock-all hooks. Match what pacman_wrapper.sh already treats as
authoritative (legacy pair still accepted).
focus_mode (never installable): install_focus_mode_daemon.sh calls
`systemctl --user`, but the checker invokes it via `sudo -u <user>` from root —
sudo changes the UID but grants no session, so XDG_RUNTIME_DIR /
DBUS_SESSION_BUS_ADDRESS were unset and it died at daemon-reload on every
automated run. Derive them from the effective UID. Now enabled+active.
compulsive_blocker (discord never limited): REAL_BINARIES hardcoded
/opt/discord/Discord — another discord package's layout. This one ships only
/usr/bin/discord, a bootstrap that downloads the app into ~/.config/discord/
and execs ~/.config/discord/Discord (a version symlink its updater maintains),
so the `[[ -x $real_binary ]]` gate failed forever. Exec the preserved LAUNCHER
(/usr/bin/discord.orig) so Discord's bootstrap/self-update still runs. Two
supporting fixes: the gate skips -x when the exec target IS the wrapper's own
.orig (the wrap creates it — chicken-and-egg), and PROCESS_MATCH is added
because the launcher exec()s away, so pgrep -f on '.orig' matches nothing and
auto-close/kill/running-state would have silently no-opped.
vbox_hosts: skipped -> n/a with a reason. VirtualBox is absent AND blocked by
pacman policy ("can bypass /etc/hosts restrictions"), so the check is not
applicable, not ducked. Installing it to satisfy a hosts check would open the
hole that check exists to close.
Also removed thesis_tracker (unused; unit not-found, script never installed).
Verified: every service ok, vbox_hosts n/a, zero errors, zero warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
check_and_enable_services.sh was lying about two things and tracking a third
the author no longer uses.
1. workout_locker: paths still pointed at python_pkg/screen_locker/, which
stopped existing when screen-locker was extracted to its own repo
(~/screen-locker). The service was installed, enabled and active the whole
time, but the checker reported a red error and its "fix" silently did
nothing. Now resolves ~/screen-locker via SUDO_USER + getent (the script
re-execs under sudo, so $HOME is /root).
2. User-service queries could never work: `sudo -u <user> systemctl --user`
has no XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS, so it always failed with
"Failed to connect to user scope bus" and reported EVERY user service as
not-enabled. Added user_systemctl() using
`systemctl --user --machine=<user>@.host` (systemd's own suggested fix) and
rerouted all 6 call sites (focus-mode, thorium, workout-locker).
3. Removed thesis_tracker entirely — not used; unit not-found, script never
installed. Help list renumbered.
focus_mode and guest_mode_removal keep reporting error: those are GENUINE
(focus-mode.service is not-found; the guest-mode policies are absent).
Verified: ground truth (as kuhy) workout-locker.service = enabled+active.
Before: checker said "not enabled". After: workout_locker ok, all 3 checks ✓.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
setup_hosts_guard.sh died partway on every run under `set -euo pipefail`,
which is why hosts-guard.path / hosts-bind-mount.service / nsswitch-guard.path
sat installed-but-disabled since 2026-02-02 and resolved-guard.path was never
installed at all — /etc/hosts had no watcher to restore it if tampered.
Two aborts, both silent:
1. `local_dot=$(grep -E '^\s*DNSOverTLS\s*=' ... | tail -1 | sed ...)` — grep
exits 1 when the key is absent, pipefail propagates, the assignment returns
1, set -e kills the script. DNSOverTLS is absent from a default
resolved.conf, so this fired on EVERY run, right before resolved-guard.path
was enabled. The fix-if-missing branch below it was unreachable because
detecting "missing" aborted first. Same trap on the ReadEtcHosts probe.
check_and_enable_services.sh already guarded its copy with `|| true`.
2. `systemctl restart systemd-resolved` — resolved is MASKED here (dnsmasq
serves DNS), so the restart failed and aborted the script on its last step,
after everything was already configured. Now skipped with a note.
Before: exit 1, resolved-guard disabled, nsswitch-guard start-limit-hit.
After: exit 0; hosts-guard.path, hosts-bind-mount.service, nsswitch-guard.path
and resolved-guard.path all enabled+active. DNS still resolves; getent ahosts
on doubleclick/googlesyndication/facebook returns 0.0.0.0; /etc/hosts still
immutable at 176888 lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
Both tools swallowed failures. execute_with_log appended one line to a log
nobody reads and returned 0, so the systemd oneshot reported SUCCESS.
check_and_enable_services printed one err line among ~100 and exited 0.
That is why the 2026-05-15 reorg silently killed the pacman-wrapper and
hosts self-repair for five months with no visible symptom: check_hosts
detected the disabled hosts guards on every single run, could not find
setup_hosts_guard.sh at the stale $CONFIG_DIR/hosts path, and still exited 0.
Driver — any failure now surfaces four ways:
1. journal at ERROR priority (logger -p user.err)
2. the systemd unit FAILS (exit 1) -> systemctl --failed / red timer
3. a critical desktop notification that never auto-dismisses
4. /var/lib/periodic-system-maintenance/LAST_RUN_FAILED breadcrumb
It also collects ALL failures rather than aborting on the first.
check_and_enable_services — a missing repair script means this tool cannot
do its job, so all 12 "script not found" sites now route through
err_missing_script(): journal ERROR priority, a terminal banner, and exit 2.
Healthy runs still exit 0 and clear the breadcrumb.
Verified: driver fed nonexistent paths -> exit 1, all 3 failures on stderr,
breadcrumb written, 3 journal ERROR entries. Live regenerate + real run ->
rc=0, all steps ok, no breadcrumb. Checker --status -> rc=0, no banner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
Immutable git-tracked files break git tooling. The pre-push ci-mirror gate
runs `pre-commit run --all-files`; end-of-file-fixer opens every file rb+
and dies with "PermissionError: Operation not permitted", so no push from a
normal checkout could ever succeed — every push had to be routed through a
throwaway worktree (a fresh checkout gets new, non-immutable inodes). The
same immutability also breaks pre-commit's unstaged-changes stash: the
`git checkout -- .` unlink fails, the restore never runs, and unrelated
unstaged edits are silently reverted.
Enforcement does not depend on these sources being locked: /etc/hosts is
itself chattr +i, hosts-guard.path watches and re-enforces it via
enforce-hosts.sh against the locked-hosts canonical snapshot, and
hosts-bind-mount.service pins it. Editing the sources changes nothing until
install.sh is re-run as root, which regenerates the guarded artifacts.
Companion to 6e3d61d (same fix for setup_midnight_shutdown.sh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
An immutable tracked file with uncommitted edits breaks every commit/push:
pre-commit clears unstaged changes with `git checkout -- .`, that unlink
fails ("Operation not permitted"), the context manager aborts, and the
restore never runs — silently reverting the user's OTHER unstaged edits
(seen twice today; an unrelated i3/config WIP had to be recovered from
pre-commit's rescue patch).
Enforcement is unaffected and does not depend on the source being locked:
/etc/shutdown-schedule.conf stays chattr +i, guard-lib keeps a canonical
snapshot + path watcher that re-enforces it, the monitor service protects
the timer, and screen_locker's ratchet only accepts same-or-stricter
values. Editing this source changes nothing until setup is re-run as root,
which rewrites the guarded config anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
Vendored makepkg's run_pacman() waits on /var/lib/pacman/db.lck existence
with no timeout BEFORE invoking pacman, so an orphaned lock hangs
`makepkg -i` forever ("Pacman is currently in use, please wait...") and
the pacman wrapper's stale-lock cleanup never gets a turn. Wrap makepkg
(mirroring the pacman-wrapper pattern) to clear an orphaned lock up front.
- Extract stale-lock logic into shared pacman_lock_lib.sh; pacman_wrapper
sources it after integrity verification and it is in the SHA256 manifest
- makepkg_wrapper: pre-flight clear for -i/--install; bypass on FAKEROOTKEY
and non-install; FAIL OPEN if the lib is missing (never brick makepkg)
- Cross-user pacman_process_running() pgrep guard: unprivileged makepkg can
detect a root pacman its own fuser cannot see (avoids removing a live lock)
- Upgrade survival: rewrap hook (Install/Upgrade only) + check_makepkg_wrapper
drift verifier + both wrappers wired into the hourly periodic driver
- Fix reorg path bugs: stale ~/linux-configuration periodic path,
$CONFIG_DIR/hosts -> scripts/periodic_background/hosts, test WRAPPER_DIR;
refresh .orig only when target isn't already our symlink (no exec loop)
- Tests: test_makepkg_wrapper.sh (18) incl. hook Operation validity; add
lib-integrity coverage to test_pacman_wrapper_security.sh (also path-fixed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsCs1GUyiinA7bg68YvAh1
Serve the personal website (extracted to github.com/kuhyx/personal-website)
at the root kuhy.duckdns.org behind the shared gitea-caddy edge, moving Gitea
to gitea.kuhy.duckdns.org.
- setup_gitea.sh: relocate Gitea to gitea.kuhy.duckdns.org and refactor the
shared Caddyfile into per-service sites/*.caddy imports (fixes the latent
bug where re-running dropped the dufs/syncyomi blocks).
- setup_personal_website.sh (new): idempotent setup/status/help; migrates
existing Caddy blocks to snippets, relocates Gitea, builds ~/personal-website,
serves dist/ from a loopback caddy (admin off) on 127.0.0.1:8088 fronted on
the root domain, reuses the DuckDNS/firewall edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KG7yQj4hP8DDpasK2e4cn7
Stands up SyncYomi (ghcr.io/syncyomi/syncyomi:v1.1.11) in Docker behind
the existing shared gitea-caddy edge at kuhy-sync.duckdns.org, so
TachiyomiSY syncs its library from a mobile network. Follows the
setup_dufs_cloud.sh pattern: loopback-bound container, one appended Caddy
site block (auto Let's Encrypt), the shared DuckDNS updater extended, and
80/443 opened via setup_wireguard_ssh.sh. Auto-generates stored 0600
credentials, onboards the first account, and mints/reuses an API key over
the local REST API, printing host + key. Idempotent setup/status/help.
Verified live: container up (restart=unless-stopped), external HTTPS
reachable, and a real phone sync landed a 13.9 MB sync_data row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbvNNUYcdNtg5NjAhYc12E
Move cloud_gallery/, the Flutter dufs_client app, and the dufs/cloud setup
scripts into a new public repo (with full git history), leaving pointers here.
The live media-cloud-sync systemd units were re-pointed to ~/dufs-cloud/scripts/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
New reusable utility that extracts each media_archive_*.zip snapshot, MOVEs its
media into ~/cloud/Media/YYYY/MM via sync_media_to_cloud.sh (dedup across the
overlapping snapshots), verifies staging drained, then deletes the zip — only
after its media is confirmed in the cloud. Staging lives on the cloud
filesystem so moves are atomic renames.
Ran over ~/Downloads/media_archive_archive: 44 zips / 27 GB → 44 imported +
deleted, 0 kept. The two 13.7 GB near-duplicate snapshots collapsed (2412
duplicates dropped). Cloud Media 4.9 GB → 19 GB (2949 files); Downloads
reclaimed 27 GB; empty archive dir removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
Replace dufs's plain file list with a React 19 + Vite + TypeScript SPA served
same-origin by dufs, so the cloud URL becomes a real gallery behind the existing
auth: thumbnail grid, image lightbox (zoom + keyboard nav), inline video, drag/
pick upload, download, confirm-guarded delete, and a simple .txt/.md editor.
Serving architecture (de-risked against the real dufs 0.46.0): --assets does not
override the UI, so dufs `render-spa` serves the app shell, the SPA lists dirs
via WebDAV PROPFIND (render-flag-immune) and streams files via GET (Range for
video seeking), with HashRouter so navigation stays at "/". Verified render-spa
leaves KeePass file GET/PUT/?hash intact (Phase 1 unaffected).
Adds generate_thumbnails.sh (ImageMagick images + ffmpeg video posters →
~/cloud/.thumbs, idempotent) and setup_cloud_gallery.sh (build + deploy the SPA
into the cloud root, enable render-spa, wire thumbnail refresh into the Phase-2
media sync). setup_dufs_cloud.sh now emits render-spa only when the SPA is
deployed, so a fresh dufs keeps its default listing.
Verified: pnpm build + lint (strictTypeChecked) clean; 38 vitest tests, coverage
92.8% stmts / 96% lines (dufs-client 100%); local dufs --render-spa serves shell
+ assets + thumbnails + PROPFIND (all 200). Live deploy left for the user (needs
sudo; enables render-spa on the public service). Note: host ffmpeg is broken
(libvpx.so.11) so video posters are skipped until it's fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
Phase 2 of the cloud roadmap. sync_media_to_cloud.sh MOVES organized images and
videos out of ~/Downloads into ~/cloud/Media/YYYY/MM (by mtime), unzipped and
browsable via the dufs URL — no duplication, the cloud folder becomes the single
storage. Move is crash-safe (mv: atomic on same fs, copy-then-unlink across fs);
files modified in the last 2 min are skipped (in-progress downloads); identical
files are de-duplicated and name clashes get a numeric suffix.
setup_media_cloud_sync.sh installs a oneshot service + 30-min catch-up timer +
a ~/Downloads path watcher, plus a drop-in ordering the existing media-organizer
after the cloud sync so media is moved before it would be zipped.
Verified: shellcheck clean; 12-assertion MOVE suite on throwaway dirs; real
move of a PNG into ~/cloud/Media fetched via the live URL (HTTP 200 image/png).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
Add "one vault, synced everywhere" for PC + Ubuntu laptop + phone, with an
identical flow and no stored master password. keepass-open prompts the master
password (in memory only), folds in strays, pulls+merges the cloud copy over
WebDAV, opens KeePassXC on ~/Keepass/Passwords.kdbx, and publishes edits on
close. Safety rests on `keepassxc-cli merge` (entry-level union) plus a dufs
?hash compare-and-swap on upload, so concurrent edits merge instead of clobber.
The consolidator enforces exactly one vault per machine: it folds a stray only
if it opens with the same master password, backs up first, never removes the
canonical, and auto-excludes the dufs serve-path (the remote store) — a
data-safety fix after the first real run tried to delete ~/cloud/Keepass.
Only the dufs *server* password (not the vault key) is cached in the keyring.
Single cross-distro installer, no role flag; the phone uses KeePassDX natively.
Verified: shellcheck clean; 8 (reconcile) + 14 (consolidator) + 8 (installer)
assertions on throwaway vaults; real PC apply with live WebDAV GET=200 and
?hash==sha256; a clean keepass-open run ("no strays / already in sync").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
Add setup_dufs_cloud.sh: a self-sustaining installer that stands up dufs
(single Rust binary; web UI + built-in WebDAV) on 127.0.0.1:5000 behind the
existing Caddy + DuckDNS exposure stack at a new subdomain, so files are
reachable from mobile network over HTTPS. An event-driven systemd path unit
mirrors ~/Keepass/Passwords.kdbx into the served folder on every change, so
KeePassDX syncs the vault natively over WebDAV.
Paranoid secret handling for the gitignored .dufs_cloud.conf (plaintext web
password): ignored at repo-root + linux_configuration + **/ globs; a new
check-no-dufs-conf pre-commit hook blocks it even against `git add -f`; the
installer refuses to write it unless git check-ignore confirms it is ignored;
the password is hashed via `openssl passwd -6 -stdin` (never in argv); and a
cloud-root guard forbids serving home/.ssh/.config/.gnupg/the repo/root.
Verified: dufs auth (401/200) + WebDAV (207) against the real v0.46.0 binary,
idempotent Caddy/DuckDNS/unit-render logic, and a live phone-on-cellular test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
setup_local_llm.sh: idempotent Bash installer for a self-hosted local LLM
stack tuned for the RTX 3090 (24GB) — installs ollama-cuda, writes a
systemd drop-in enabling flash attention + Q8_0 KV-cache quantisation,
pulls the model, creates a custom Ollama model with a developer system
prompt, and launches Open WebUI via Docker.
Verification: bash -n and shellcheck -x clean (see evidence artifact).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
Add a self-expiring override system so specific late-night live events
(2026 World Cup matches) can be watched without the auto-shutdown killing
the PC, without weakening the permanent schedule ratchet:
- shutdown-override-manager.sh (add/list/remove): add is friction-gated
(typed confirmation phrase + 30s delay, max 12h, max 3 windows) and
writes absolute-epoch windows to /etc/shutdown-schedule-overrides.conf.
- day-specific-shutdown-check.sh now skips the shutdown decision inside an
active window (pruning expired entries) and gained a DRY_RUN guard around
rtcwake/poweroff/hibernate for safe testing.
i3blocks shutdown_countdown.sh: show the exact next-shutdown clock time
(not a relative countdown), show the overridden end time in green when an
override is active or will rescue the upcoming shutdown, and make the
skip-dates check fork-free (builtin read loop) so the hot path spawns no
external processes.
install.sh: deploy i3blocks by symlinking each repo file into
~/.config/i3blocks instead of cp -r (the source of silent drift), cd to
the script dir so it runs from anywhere, and chmod +x only *.sh so the
config file stays 0644.
Repair the previously-dead i3blocks regression suite (stale path), update
the shutdown assertion to the new absolute-time output, add hermetic
override coverage, and keep the <=1-exec fork-count guarantee.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmwYH5RfThZGkVD2jE61Da
setup_dns_blocker.sh turns this PC into a DNS server that serves the
identical /etc/hosts blocklist to the whole LAN with no per-device app,
by feeding generate_hosts_file.sh output into dnsmasq via addn-hosts
(same StevenBlack variant + custom entries + unblocks). Adds status/
refresh subcommands, a daily refresh timer, Restart=always, and logging
to /var/log/dns-blocker/.
Because the ISP router cannot advertise a custom DNS server, a dhcp/
dhcp-off mode makes the PC the LAN DHCP server (static IP via nmcli with
a gateway-ping self-check and safe, always-online rollback), handing out
itself as the DNS server so every device is blocked automatically.
setup_wireguard_ssh.sh gains an allow-dns subcommand that opens DNS (53,
LAN saddr) and DHCP (67) in the single-owner nftables ruleset. The DHCP
accept rule is placed before 'ct state invalid drop' because the
0.0.0.0->255.255.255.255 DHCP broadcast is classified INVALID and was
otherwise dropped. Firewall detection now reads the actual loaded ruleset
(a ruleset can be loaded while nftables.service reads inactive) and avoids
a grep -q/pipefail SIGPIPE false-negative.
Verified end-to-end: Pixel-6a leased 192.168.1.82 with DNS 192.168.1.43;
youtube.com blocked, legit sites resolve. Regression tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pqSnrLt8QoWup7n2Ymk77
Replaces the bespoke chattr/bind-mount/systemd-watcher implementations for
/etc/hosts and /etc/shutdown-schedule.conf with the new shared guard-lib
(~/guard-lib, guardctl), so screen-locker and steam-backlog-enforcer's new
block-gaming feature stop maintaining parallel copies of the same
tamper-resistance mechanism.
- pacman_wrapper.sh: pre/post hook fallbacks now call guard-lib's generic
unlock-all/relock-all scripts (covers every registered guard instance,
not just /etc/hosts)
- setup_midnight_shutdown.sh: installs/updates its guarded config via
guardctl file-guard instead of hand-rolled chattr + systemd unit
generation; the schedule ratchet logic (block-if-more-lenient) stays
bespoke since guardctl's generic unlock can't represent it
- new hosts/guard/plugins/nsswitch-plugin.sh, resolved-plugin.sh
Also fixes, at user's request even though pre-existing: 3 shellcheck
SC2329 false positives in pacman_wrapper.sh (functions invoked indirectly
by name, not actually dead) and 1 SC2001 style warning (echo|sed VM-name
extraction replaced with parameter expansion, verified equivalent output).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
Deploys Gitea+Caddy (auto-HTTPS via Let's Encrypt) at kuhy.duckdns.org,
extends setup_wireguard_ssh.sh with an allow-web firewall subcommand, and
mirrors all 21 GitHub repos (5 private) via Gitea's native pull-mirror.
Runs containers with host networking to work around a discovered bug where
this host's nftables forward-chain silently blocks Docker bridge egress.
Adds a self-hosted-service-exposure skill capturing the reusable pattern
and gotchas for future public-facing deployments.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5jnu99ZuENSkuFQKLcSdh
The hibernate-on-alarm-night case block in create_shutdown_check_script()
escaped $ as \$ for an unquoted heredoc, but the heredoc is opened with the
quoted delimiter <<'EOF', so the backslashes were written literally into
the deployed check script. Every timer fire since commit 0d54c5d hit a bash
syntax error and exited before evaluating the shutdown window, so the PC
never auto-shut-down (confirmed failing at 23:00/23:01 last night and 07:01
this morning). Also fixed an off-by-one (seq ... 24 -> 23) that was emitting
invalid OnCalendar=24:00:00 entries systemd had to ignore.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGHLfbAPmak6HtGJby57eo
Lets SSH terminal access reach this PC from a phone on a different network
(mobile data vs home broadband), using only FOSS/free software: self-hosted
WireGuard (no relay/coordination server), DuckDNS for the dynamic public IP,
and a default-drop nftables firewall so sshd is never exposed to the WAN
directly -- only the WireGuard UDP port is forwarded, SSH is reachable only
through the tunnel or LAN.
Verified fully end-to-end (phone on mobile data, real handshake + SSH login).
Several bugs only surfaced through live execution and were fixed in place:
a DNS=1.1.1.1 line that broke all phone DNS once the tunnel was active, a
require_root/sudo arg-forwarding bug, hostname/dig not being installed on a
minimal Arch system, a bash RETURN-trap scoping bug, and a DuckDNS cron-dedup
that would have deleted an unrelated pre-existing Joplin DuckDNS cron entry.
Also whitelists the WireGuard/F-Droid/ConnectBot apps (plus the todo app) in
phone_focus_mode's WHITELIST so the GPS-based focus daemon doesn't disable
them. Adds "iif" (nftables keyword) to the codespell ignore-list since it
was flagged as a false-positive typo of "if".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUSBRyujRMuGiUitGP8gET
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.
Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deleted screen_replay.sh (x11grab ring-buffer) and kill_stale_recorders.sh
(stale-ffmpeg killer). Removed autostart exec and Mod+Shift+F12 binding from
i3/config; removed replay startup from dwm-session. Both scripts archived in
testsAndMisc-archive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
turn_off_auto_idle_screen_shutdown.sh --watch-controller forked 4 xset +
1 xdotool per joystick event (blocking on a dd read, debounced by sleep
0.3) — ~21 procs/s while a controller was connected during gaming. The
xset poking was redundant: startup already disables DPMS/blanking.
Replace reset_idle_activity / watch_js_device / the polling watcher with
one long-lived `systemd-inhibit --what=idle:sleep` held only while a
/dev/input/js* device is present, re-evaluated on udev input add/remove
events (event-driven; 30s presence-poll fallback). EXIT+INT/TERM traps
release the inhibitor on every termination path.
Verified live: subtree stays {systemd-inhibit, udevadm} with zero
dd/xset/xdotool; exactly one inhibitor held; clean release on SIGTERM,
no orphans. Takes that loop from ~21 forks/s to 0.
Behavior change: keeps the session awake while a controller is connected
(not only during active input).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
atop's `-P PRC` output inserts the clock-tick rate (HZ=100) between the
`state` and `utime` columns. Both the Python parser and the native C
aggregator read that constant as utime for every record, charging a flat
1 CPU-second per record — so cpu_seconds collapsed to pid_count and
short-lived fork-storm commands (xset, dd, chronyc) topped the CPU table
(xset showed 67h). The old test fixtures lacked the HZ field, so code and
tests agreed on the bug.
- _parse_prc / atop_agg.c: read utime/stime past the HZ field (after+2/+3,
tokens[10]/[11]); bump the length guards accordingly
- restore C/atop_agg (deleted in 89b4f59) under linux_configuration/C/,
where the build path resolves; corrected test fixtures to include HZ
- _atop_agg_binary: fall back to the Python parser when the C source tree
is gone instead of trusting an orphaned cached binary
- add regression tests proving HZ is not summed as CPU
- bundle the in-progress since-last-report multi-day aggregation (segments,
-b/-e bounding, persisted state, window merging) and its tests/conftest
- meta: gate linux_configuration/tests in pytest_changed_packages.py
Verified by running usage_report.py --date 20260604: Top CPU now led by
SkyrimSE; xset/dd/chronyc fall to ~0. C unit tests + full pytest suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- install.sh: install sleep-hook.sh to systemd-sleep hooks (step 3) and
shutdown-wrapper.sh to /usr/local/bin/shutdown (step 5)
- shutdown-wrapper.sh: new script that intercepts shutdown calls and
redirects to rtcwake -m disk on alarm nights (Mon/Fri/Sat/Sun), pass-
through for reboots and cancel commands
- sleep-hook.sh: new systemd-sleep hook that restarts wake-alarm.service
for each logged-in user after hibernate resume
- setup_midnight_shutdown.sh: check wake-alarm day; if yes set RTC alarm
and hibernate, otherwise fall back to systemctl poweroff
- linux_configuration/tests: update script paths after periodic_background/
reorganisation (hosts_file_monitor, makepkg_capped, music_parallelism,
shutdown_timer_monitor, usage_monitoring_installer_efficiency)
- test_i3blocks_efficiency.sh: remove checks for HEARTBEAT_INTERVAL_S and
WARP_POLL_INTERVAL_S constants that no longer exist
- test_pacman_wrapper_security.sh: remove tests 20-21 (builtin time helpers /
external date calls) that are no longer applicable; update path
- generate_hosts_file.sh: add sed unblock rules for delio.com.pl and
loverslab.com to stay consistent with install.sh whitelist
- steam_backlog_enforcer/scanning.py: remove unplayable_reason arg from
logger.info call (too many format args); drop matching test assertion
- steam_backlog_enforcer/tests/test_protondb.py: add
test_unplayable_reason_no_trending_tier to restore 100% branch coverage
on protondb.py line 97 (was previously covered indirectly)
- Screen locker: disable VT switching (Ctrl+Alt+Fn) via setxkbmap
srvrkeys:none on startup; restore on close (production mode only).
Gracefully skips if setxkbmap is not installed (shutil.which).
Tests: 7 new tests, 100% branch coverage maintained.
- Midnight shutdown: restore real schedule values (Mon-Wed 21:00,
Thu-Sun 22:00, morning end 05:00); re-enable the three commented-out
leniency checks in check_schedule_protection(); self-lock script with
chattr +i at end of enable_midnight_shutdown().
- Hosts install: add UNBLOCK_STATE_FILE tracking for whitelisted domains;
check_unblock_entries_protection() blocks installation if the unblock
list grows; save state after install; self-lock install.sh and
generate_hosts_file.sh with chattr +i.
- Move all linux_configuration scripts into two semantic categories:
- single_use/: scripts run once manually (fresh install, fixes, setup)
- periodic_background/: scripts run by systemd timers or daemons
- Preserve existing subdirectory structure within each category
- Fix lib/common.sh source paths for new directory depths
- Fix CONFIG_DIR depth in setup_periodic_system.sh and check_and_enable_services.sh
- Update all references in tests, fresh-install/main.sh, nix modules, and docs
- Fix check_polling_antipatterns.sh false positives (||, regex |, case patterns, jq strings)
- Fix pre-existing mypy exclusion path and type annotations for moved tools/ directory
- Rewrite check_polling_antipatterns.sh using awk (no bash regex loops); add require_serial: true
- music_parallelism.sh + thesis_work_tracker.sh: tighter state-output and
error paths; expanded regression tests.
- hosts-file-monitor.sh + shutdown-timer-monitor.sh: harden against partial
failures, matching new test branches.
- i3blocks persist_common.sh helper improved (consumed by activitywatch /
warp status blocks).
- setup_midnight_shutdown.sh + thesis_work_status.sh: state parsing tuned.
- pacman_blocked_keywords.txt: drop one obsolete entry.
- New test_thesis_work_status.sh regression script.
All six bash regression tests pass.
- music_parallelism.sh: restore batched-pgrep/xdotool optimization that exists
in /usr/local/bin/ but had been lost from the repo, plus bump intervals
(FAST 0.5s->2s, IDLE 3s->10s) to further cut fork rate
- organize_downloads.sh: replace per-file tr fork with bash 4 ${var,,}
parameter expansion, replace per-log-line $(date) fork with printf %()T builtin
Follow-up to fork-storm fixes after observing 122 CPU-hours of date calls
in a 90-minute window (root cause was nvidia-pmon-logger.sh, fixed separately).
Resource-usage report showed ~29 cores of average load coming from i3blocks
helper scripts forking awk/tr/grep/bc/sensors/nvidia-smi every tick. Rewrite
all five hot-path scripts to eliminate forks:
- volume.sh: persist mode, blocks on 'pactl subscribe' event stream.
No polling, no sleep, no fork per tick.
- gpu_monitor.sh: persist mode, single long-lived 'nvidia-smi --loop=5'
feeds a bash 'while read' loop. Falls back to /sys for amdgpu.
- battery_status.sh: reads /sys/class/power_supply/BAT*/ directly.
Zero forks; replaces 'acpi | awk' pipeline.
- cpu_monitor.sh: reads /proc/loadavg and k10temp/coretemp /sys/class/hwmon.
Zero forks; replaces 'sensors | awk | tr' + bc arithmetic.
- motherboard_temp.sh: reads nct*/it*/f71* Super-I/O hwmon node directly.
Zero forks.
Configure volume + gpu_monitor with interval=persist so i3blocks keeps
one long-lived producer each instead of forking per tick.
Also add:
- kill_stale_recorders.sh -- kill stray ffmpeg x11grab / dotnet-trace /
dotnet-monitor processes left running after sessions.
- monitors.slice -- resource-capped user slice (CPUQuota=50%,
MemoryMax=512M, MemorySwapMax=0 for zram safety, TasksMax=256) to
bound future monitoring regressions.
- efficient-polling-scripts SKILL -- rules for writing status-bar and
polling scripts without forks; fork-pipeline to bash-builtin translation
table; verification checklist.
Verified live: strace -c on cpu_monitor.sh shows 1 execve / 0 clones;
persist producers (pactl subscribe, nvidia-smi --loop) show 0 CPU ticks
over a 3s idle sample. Per-invocation timing 1.6-1.9 ms (was 30-80 ms).
- New companion Android app (com.kuhy.focusstatus) under
phone_focus_mode/focus_status_app/ with a pure-Java, Gradle-less
command-line build pipeline (build.sh). Shows an ongoing
notification titled 'Focus: HOME / AWAY / DAEMON DOWN' with
distance, GPS, disabled-app count, last check, daemon checkmarks,
and a 'Re-check now' action button.
- focus_daemon.sh: write_status_snapshot() + sleep_with_recheck()
for JSON status + early-wake on trigger file. init() chmods
STATE_DIR 777 so the app can drop the trigger file.
- config.sh: new STATUS_FILE / RECHECK_TRIGGER; WHITELIST expanded
with com.kuhy.focusstatus and 11 more user-requested apps
(podcini X, mpv, bible/openbible, pkp/portalpasazera, orange,
runnerup, splitbills/splitwise, xiaomi smarthome).
- focus_ctl.sh: new 'recheck' + 'notif-status' subcommands.
- deploy.sh: new step [7/7] builds APK, installs, grants
POST_NOTIFICATIONS, pre-approves Magisk SU policy, launches
foreground service.
- .gitignore: exclude focus_status_app/build symlink + debug.keystore.
End-to-end verified on device: notification live with real values;
Re-check button triggers a daemon location check within ~1s.
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage)
- Split oversized test files to stay under 500-line limit
- Add per-file ruff ignores for test-appropriate suppressions
- Fix _cache_decks.py to properly convert JSON lists to tuples
- Add session-scoped conftest fixture for logging handler cleanup (Python 3.14)
- Update ruff pre-commit hook to v0.15.2
- Add codespell ignore words for test data
- Add generated output files to .gitignore
Add is_autoclose_suspended() function that checks for a per-app
suspend file dated today. When suspended, launches the app directly
without the auto-close timer via exec.
- Add type annotations and docstrings to focus_mode_daemon.py, transcribe_fw.py, transcribe_helpers.py
- Use logging module instead of print
- Fix bare except clauses with specific exception types
- Add enforce-resolved.sh: validates ReadEtcHosts=yes, prevents
DNSOverTLS bypass, removes drop-in overrides, locks drop-in dir
- Add resolved-guard.path/service: watches /etc/systemd/resolved.conf
and its drop-in directory for tampering
- Update pacman hooks to unlock/relock nsswitch.conf and resolved.conf
alongside /etc/hosts during package transactions
- Extend setup_hosts_guard.sh with --skip-resolved option, resolved
canonical snapshot, drop-in directory locking, and enforcement
- Add resolved.conf checks to check_and_enable_services.sh: validates
ReadEtcHosts, DNSOverTLS, drop-in overrides, immutable attribute,
and resolved-guard.path status with auto-fix capability
Fixed on live system: ReadEtcHosts was set to 'no' and nsswitch.conf
was missing 'files' in the hosts line, completely bypassing /etc/hosts.
Move warsaw_bridges, warsaw_districts, warsaw_landmarks, warsaw_metro,
warsaw_osiedla, warsaw_streets, and car_brand_logos into the existing
python_pkg/anki_decks/ directory alongside the polish_* generators.
Also move preview_all.html into anki_decks/.
Update all import paths, filesystem references in geo_data.py,
docstrings, READMEs, test imports, and .gitignore accordingly.