Commit Graph

254 Commits

Author SHA1 Message Date
82e206116a Make the geofence tell the truth, and the phone explain itself
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The enforcer failed in both directions: nothing blocked at home, and
everything blocked at the office. The comparison was never inverted --
`metres <= threshold` was always right. Three separate causes:

Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a
~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed
outside it, read as AWAY, and restored every app the geofence exists to hide.
FINE is now declared and self-granted by the device owner, along with coarse
and background, re-asserted every pass so a reinstall cannot silently drop it.

There were three states but two behaviours. LOCATION_UNKNOWN applies the
identical sweep to AT_HOME -- correct fail-closed design, indistinguishable
from outside. And nothing ever requested a fix: the pass read whatever another
app happened to cache, so at the office with no navigation running the cache
was empty. Passes now acquire a fix inside a location-typed foreground
service, bounded at 20 s, and treat anything older than 30 min as no fix. That
window is deliberately longer than the 15-minute cadence; a shorter one marks
the newest cache stale on every pass of an idle phone and blocks everything.

None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty
while dumpsys showed 82 alarms had fired, and `run-as` is refused on the
release build device owner requires. So the pass now writes a ring-buffered
JSONL record read back in-app: reason, distance vs the fence, fix
age/provider/accuracy, curfew state, and every hidden app with the rule that
hid it. Coordinates are never recorded -- a distance is enough to debug a
geofence, and this is a home address.

Also fixed along the way:

- Hysteresis keyed off `isEnforcing`, which is true under AWAY too because
  YouTube stays hidden, so the threshold was permanently radius+hysteresis.
  It now keys off whether the previous pass was inside the fence.
- The scheduler's curfew-boundary alarms were documented but never scheduled;
  both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late.
- Installing over the app cancelled its pending alarms, so `adb install -r`
  silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED
  now re-arms it, verified on device without launching the app.
- A wrong-typed log record threw instead of being skipped, and the exception
  escaped far enough to leave the page stuck busy -- which disables the
  release-device-owner button, the one control that must work when everything
  else has gone wrong.
- `lib/enforcement.dart` was a second decision layer with no always-blocked
  concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed.
  It would have made the new screen report YouTube visible while it is hidden.
  Deleted; the UI renders the Kotlin record rather than re-deciding.
- Anchoring home accepted a stale or unknown-precision fix. A wrong home
  offsets the fence everywhere and forever, which is what produced a 766 m
  reading while standing at home.

Policy: Play Store is allowed by day (not during curfew) so infakt can be
installed and updated; the sweep is default-deny for third-party packages, so
anything installed from Play is hidden on the next at-home pass. Tachiyomi's
per-source extension apks are covered by a new prefix allowlist rather than an
exact list that goes stale whenever an extension is installed.

`export.py` stopped subtracting the allowlist from `blockable_system_packages`.
Sweepable is not hideable: that list decides which system packages are
eligible for a decision, and `isAllowed` is what protects them. Removing an
allowed system package from the sweep freezes it in its last state -- it would
have stranded Play hidden permanently the moment it was allowlisted.

Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a
0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from
`pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi
packages present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
2026-08-14 13:43:05 +02:00
47371ac9a1 Wire uBlock Origin into every real Chromium launch, alongside LeechBlock
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
install_ublock.sh patches the same wrapper install_leechblock.sh already
targets, always rewriting --load-extension into a single comma-joined
flag rather than appending a second occurrence — verified empirically
that Chromium is last-wins across separate --load-extension flags and
would otherwise silently drop LeechBlock.

seed_ublock_storage.js previously hardcoded uBlock Origin's Chrome Web
Store extension ID, but --load-extension (unpacked) loads get a
synthetic, install-path-derived ID instead. It now reads the real ID
back out of the profile's own Preferences file, with a --ext-path
override for installers whose package path differs from Arch's.

install_ungoogled_chromium_ubuntu.sh's UBLOCK_EXT_PATH was an unverified
guess (/usr/share/webext/ublock-origin-chromium); corrected to the real
Debian/Ubuntu package path confirmed via packages.ubuntu.com.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yfDYbaw1D1WhVMqWhsSDA
2026-08-12 22:44:28 +02:00
0c28f9988f Fix pacman whitelist matching for yay's -U package-file installs
is_blocked_package_name compared raw pacman -U <file>.pkg.tar.zst
arguments against whitelist entries, which never matched since yay
always installs AUR packages this way. Added strip_pkgfile_suffix()
to derive the bare package name first, plus a regression test.

Also swaps the browser whitelist entries to ungoogled-chromium
(Chromium 151 dropped MV2, breaking uBlock Origin's full/classic
build).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yfDYbaw1D1WhVMqWhsSDA
2026-08-12 22:43:30 +02:00
a97ad66e1f Whitelist RunnerUp day and night, so it can always sync
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
RunnerUp needs to stay enabled after a run to export the TCX file
screen-locker verifies against; being disabled by focus mode at home
silently blocked that. It's straightforwardly beneficial, so treat it
like the other always-available apps: present in both WHITELIST and
NIGHT_WHITELIST.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SH7XBkpEqQX4KghHdgx9T
2026-08-12 19:22:50 +02:00
2d4d2a2eb2 Block by hiding apps, which the phone cannot undo
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled
The network layer was the wrong place for this. Tested remotely today: opening RethinkDNS and switching off its device blocklists un-blocked YouTube in a few taps, and no device owner API can stop that -- an on-device filter app always owns its own rules.

App hiding is different. 'pm unhide' from adb shell throws a SecurityException and the package stays hidden; only the device owner can reverse it, and reaching that costs a factory reset. So the block moves there, scoped to an explicit allowlist and the home geofence.

Both whitelists are rewritten from the apps that actually exist on this phone. The old ones described the rooted Blackview. Every browser is excluded now, so at home a link does not open at all -- confirmed as intended.

LAUNCHER_PACKAGE was still the Blackview's launcher, which is not installed here. The exporter refused to render the policy twice over it -- precisely what that check is for.

The network layer stays a soft deterrent by choice: pinned and uninstall-blocked, but without lockdown, and its blocklists remain switchable.

Private DNS is implemented but ships disabled. stunnel logged zero connections from the handset across two pin attempts, so the router does not forward 853.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-11 19:49:11 +02:00
62862c01ab Close the two holes that made the network block advisory
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Both were measured, not theorised. `pm uninstall --user 0
com.celzero.bravedns` succeeded while the VPN was pinned and
DISALLOW_CONFIG_VPN was set -- I ran it to check whether the provider
was protected, and it removed the app, took the DNS filter with it, and
left the always-on pin aimed at a package that no longer existed. The
second hole was quieter: without lockdown, stopping the tunnel let
traffic flow normally, so the filter only held while it was running.

So the provider now gets setUninstallBlocked, re-asserted every pass,
and the pin runs with lockdown. Lockdown is a real hazard and is
deliberate: if the VPN app breaks, the device has no connectivity until
ownership is released. That is only survivable because the release path
lives in the enforcer app and needs no network.

releaseDeviceOwner reads the pinned package before unpinning it, then
unblocks its uninstall. Reversed, getAlwaysOnVpnPackage returns null
after the unpin and the provider would stay unremovable forever -- the
same trap the self-uninstall ordering already avoids.

Home coordinates are provisioned. The pass now logs "applied AT_HOME"
rather than LOCATION_UNKNOWN, so enforcement is finally geofenced rather
than permanent-everywhere, driven by a real fix.

`pm uninstall --user 0` on the provider now returns
DELETE_FAILED_OWNER_BLOCKED.

What this does NOT fix: RethinkDNS's blocklists are gone with the
uninstall, since they live in app-private storage. The filter is running
with no rules and youtube.com resolves normally until they are
re-entered. And nothing here stops those rules being switched off from
inside the app -- no device owner API can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 22:33:11 +02:00
6836a32b43 Let the app set its own home, and lock VPN configuration
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Two gaps left from the device owner work.

The geofence had no way to be provisioned. push_home_location.sh stages
the file through `run-as`, which returns "package not debuggable" on the
release build device owner requires, and making the build debuggable to
fix that would hand anyone with adb the ability to edit this app's state
-- the bypass the whole design exists to close. So the app writes its
own filesDir instead, from a button.

Deliberately not an exported intent: anything that can set home from
outside the app can set it somewhere else, and the geofence would then
report AWAY forever with enforcement off. The coordinates are never
logged either, since that is a home address and logcat is readable by
anyone with adb.

DISALLOW_CONFIG_VPN is now applied from the app, after the filter was
confirmed working rather than before -- locking VPN configuration over a
broken setup would pin the broken state in place. The open question was
whether it also freezes RethinkDNS's own settings; it does not. Its
blocklist selection was changed under the restriction without trouble,
which is the answer that matters, because the alternative meant every
domain rule had to be final before locking.

The status page gained a Geofence row. "no home set - enforcing
everywhere" is a large behavioural difference from a working geofence
and was previously visible only by inspecting logcat.

The widget tests caught a real bug rather than a test-only one: the two
new buttons overflowed the column by 4px, and the control that must
never be clipped off the bottom of the screen is "Release device owner".
The body scrolls now.

Verified after a reboot: youtube hidden, all 14 youtube domains at
127.0.0.1, google/gmail/play resolving normally, no_config_vpn still
enforced, uninstall still refused. The geofence itself stays untested on
hardware until the phone has a GPS fix to record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 22:17:31 +02:00
db1d8f8247 Pin RethinkDNS as always-on VPN, the layer that reaches every app
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Hiding the YouTube package stops the app. It does nothing about
youtube.com in Firefox, in a webview, or in any client nobody has
enumerated. This is that layer.

setAlwaysOnVpnPackage from the device owner is not the mechanism that
failed on 2026-08-09 -- that was `settings put secure always_on_vpn_app`,
which writes successfully, survives a reboot, and is ignored by
VpnManager. The DPM call takes effect: dumpsys now reports
mAlwaysOnVpnPackage=com.celzero.bravedns.

Lockdown stays off. It drops all traffic whenever the tunnel is down,
and the wipe checklist names it together with the DISALLOW_*
restrictions as the combination most likely to leave an unrecoverable
device. DISALLOW_CONFIG_VPN is deferred for the same reason: locking VPN
configuration before the filter is confirmed working would pin a broken
setup in place.

The exporter emits always_on_vpn_package only when the provider is also
in never_disable_prefixes, and RethinkDNS was added there. Pinning a
package the sweep can hide is the worst case -- the restriction would
point at a hidden app and cost general connectivity, not just the
filter. The pin is re-asserted every pass and, unlike the uninstall
block, does not track the decision: the network filter must not lift
when the geofence says AWAY.

releaseDeviceOwner now unpins the VPN and clears the restriction before
clearing ownership, for the same reason it already unblocked uninstall.

Not yet active, and the commit does not pretend otherwise: the tunnel
needs a one-time in-app VPN consent tap that adb cannot grant, and
RethinkDNS is at first-run state because its wildcard rules live in
app-private storage that the backup never reached. They have to be
re-entered by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 21:40:04 +02:00
b1de6f8d38 Take the geofence out of YouTube's reach, and pin infakt on
Leaving the house was an off switch. The AWAY branch returned early with
packagesToShow = every installed package, so walking 150m from home
restored YouTube -- which is the exact affordance device owner was
provisioned to remove.

always_blocked_packages is exempt from all four branches: AWAY hides
them, the allowlist loop cannot show them, and the workout release skips
them, because otherwise "go for a run" is the off switch instead. Chrome
is included as a second route to the same content; Firefox stays, and
carries the uBlock filters. Play is deliberately left geofenced so apps
can still be installed away from home -- and a hidden package cannot be
reinstalled from Play anyway.

infakt moves from the allowlist to SYSTEM_NEVER_DISABLE. Allowlisting
only held while it stayed in BOTH the day and night lists, and dropping
it from either would silently make it hideable; isAllowed() consults the
never-disable list before the curfew split, so it now holds under every
condition. It is device-paired to a bank over SMS, so losing it strands
the same re-authentication chain a hidden Messages app would.

The exporter now refuses an always-blocked package that is not also
blockable: the runner filters unopted system packages out of
installedPackages, so such an entry would never be hidden and nothing
would say so.

Verified: 128 Python tests at 100% branch coverage, 16 Kotlin tests, the
new ones canary-checked by breaking an assertion and confirming the
suite actually fails. On device, youtube/music/chrome/vending are
hidden=true, infakt hidden=false, uninstall still refused.

The AWAY path itself is unit-tested but NOT verified on hardware: with
no home_location.json every pass decides LOCATION_UNKNOWN, so the branch
is unreachable on this phone until coordinates can be provisioned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 21:28:25 +02:00
6bf7343c0f Make the enforcer real: block YouTube, and refuse to be uninstalled
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The device owner is provisioned, so enforcement stops being theoretical.
Three things had to be true before it was safe to switch on.

Release signing. A device owner can only be replaced by an APK with the
same signature, and `dpm remove-active-admin` does not work on one, so a
debug-signed device owner could never be updated -- the only exit would
be another factory reset. focus_owner now uses the shared key.properties
config the other apps use. No minify: an untested ProGuard pass that
strips a DevicePolicyManager callback produces a device owner that
cannot release itself.

Uninstall blocking. Device owner hides Settings' uninstall button, but
`adb uninstall` still worked, and that is the route that strands
ownership with no holder. setSelfUninstallBlocked closes it, re-asserted
every pass so it self-heals. The ordering in releaseDeviceOwner is not
stylistic: unblock, THEN clear. Reversed, the block outlives the
ownership required to lift it.

The allowlist. config.sh describes the rooted phone, so the hide set was
computed on the PC before any pass ran -- which caught pl.infakt.infakt
in it. infakt is device-paired to a bank over SMS, so hiding it would
have stranded the same re-authentication chain as hiding Messages.

Verified on the Pixel 6a as a real device owner: hid 27, and youtube,
youtube.music, chrome, vending and videos are all hidden=true, while
phone, messages, keepass, mbank, revolut, infakt, mobywatel, settings
and signal stay hidden=false. `adb uninstall` returns
DELETE_FAILED_DEVICE_POLICY_MANAGER. All of it survives a reboot, which
is exactly what `pm suspend` and `pm disable-user` failed to do here.

restore_apps.sh reinstalls the 54 backed-up packages via
install-multiple, since split APKs make a bare `adb install` fail. It
skips the device owner's own package by name.

Enforcement is permanent for now, not geofenced: push_home_location.sh
delivers coordinates through `run-as`, which is dead on a release build,
so every pass decides LOCATION_UNKNOWN and fails closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 21:12:53 +02:00
eeaf9590da Let the sweep hide preinstalled apps, one named package at a time
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The Device Owner sweep skipped every FLAG_SYSTEM package, which meant it
could not touch the apps it exists to block: YouTube ships at
/product/app/YouTube as a system app, and so does Chrome.

Deleting the filter is not the fix. Measured on the Pixel 6a, that would
expose 320 system packages of which 243 match no allowlist entry and no
never_disable_prefix -- including com.android.cellbroadcastreceiver
(emergency alerts), com.android.credentialmanager and
com.android.devicelockcontroller. So system apps stay default-deny and
blockable_system_packages is the opt-in.

com.android.vending is on the list deliberately: leaving Play reachable
makes every other removal a one-tap undo.

The list lives in the Python exporter rather than config.sh because it is
a safety boundary, and a shell typo should not be able to widen it. The
exporter subtracts anything already allowed, so an asset can never state
that a package is both allowed and blockable. Kotlin reads the field with
a new optionalStringSet: an asset predating the field parses to the empty
set instead of throwing, because a throw makes decide() skip the pass and
silently disable enforcement.

Verified on-device under a profile owner: hid 1 for YouTube, restored 1
when temporarily allowed, and cellbroadcastreceiver, credentialmanager,
devicelockcontroller and settings all left hidden=false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 20:24:31 +02:00
553c395cff inFakt is SMS-only 2FA, so the wipe cannot strand the account
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
This was the last blocker on the Device Owner decision. Checked in the
web panel: "Aplikacja weryfikacyjna" is unconfigured, so no TOTP seed
exists for a wipe to destroy, and only "Wrazliwe" actions are gated --
"Logowanie" is unchecked, so plain login needs the password alone. The
registered SMS number lives on the SIM, which survives.

The browser-login approval tap is a Firebase push prompt
(FirebaseInstanceIdReceiver on the device). Push tokens are per-install,
so a reinstall re-registers; and if that ever misbehaved, SMS is still
the fallback.

Corrects the wipe checklist, which assumed TOTP secrets were "likely"
inside the KeePassDX database and treated the .kdbx as a seed backup.
Neither holds: the canonical .kdbx is on the PC, and there are no seeds.
Earlier advice in this session to fetch inFakt backup codes was likewise
for the OTP-app variant this account does not use.

What still does not survive is unchanged and unrelated to 2FA: the
banking pairing ("Usun powiazanie z telefonem") and the local app PIN,
re-paired via SMS like mBank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23
2026-08-10 20:04:06 +02:00
7b1a1656f7 Back up what adb can actually reach, and block YouTube in Firefox too
phone_backup.sh captures package lists (including what was purged for
user 0), every third-party APK with its splits, /sdcard, all three
settings namespaces, and the focus-mode state. Verified: 54/54 packages,
126 APK files, 5.1 GB, splits intact.

It is deliberately not sold as a full-device backup, because on an
unrooted phone it cannot be one: /data/data is unreachable, so logins,
in-app data and device pairings do not survive. It writes a
RESTORE-BY-HAND.md naming each of those, with a restore order that avoids
a lockout -- mObywatel last, because activating it on a new device
deactivates the old one.

The first two runs pulled 1 of 54 APKs and then 6 of 54: `while read <
file` with adb inside the loop lets adb drain the same stdin. Reading
into an array first fixes it, and verify_backup now compares package
count against the manifest rather than only checking that the files it
did pull are valid zips -- integrity alone called the 1-of-54 run a pass.

Also documents the uBlock Origin filters, which block YouTube in Firefox
independently of RethinkDNS: force-stopping the VPN still leaves
youtube.com blocked by ||youtube.com^. And records what this is worth --
Play offers a one-tap Install for YouTube, the VPN is a few taps from
off, and the uBlock page has a Proceed button. Three independent layers,
none of them tamper-proof; only Device Owner removes the off switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23
2026-08-10 19:43:28 +02:00
10fff1d1a8 Block YouTube on the unrooted Pixel, and trust more than one phone
`pm uninstall --user 0` unregisters a package for one user while leaving
the APK on the system image. It needs no root and no factory reset, and
unlike `pm suspend` and `pm disable-user` -- both measured to be cleared
by a reboot on this device -- it survives one. Applied to the YouTube
app, YouTube Music, and Chrome; `--restore` puts any of them back with no
download.

focus_owner could not have done this: its sweep filters to third-party
packages, and YouTube ships at /product/app/YouTube with FLAG_SYSTEM, so
the Device Owner path would skip it even after the wipe.

Verified on 23181JEGR08034 across two reboots: all three read
installed=false, and in Firefox m.youtube.com, www.youtube.com, a /watch
URL and a youtu.be short link all fail to connect while example.com
loads. The web half of that is RethinkDNS domain rules, which live on the
device and are not in this commit; docs/youtube-block-unrooted.md records
how to rebuild them, and what this does NOT resist -- the VPN is a few
taps from off, which only Device Owner can prevent.

The trusted-device record becomes one file per device, so managing a
second phone no longer evicts the first. Unenrolled devices still abort,
and a changed model or build fingerprint aborts rather than falling
through to the next record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23
2026-08-10 19:16:01 +02:00
b73811c233 Add android_ui: drive Android apps by element, never by coordinates
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Shared across every repo with an Android surface (screen-locker's workout_app,
todo, dufs-cloud/app). Works against a plain release APK on a physical device
over adb — no root, no emulator, no Flutter debug connection, no app changes.

Driving an app by tapping pixel positions read off a screenshot fails in ways
that look like success. All observed in one session (2026-08-10) verifying
workout_app's Firebase restore:

* A tap aimed at a password field landed with the field unfocused. The typed
  password went nowhere and NOTHING errored — the next screenshot just showed
  an empty box. type_into/type_into_field now re-read the field and raise.
* Opening the soft keyboard moved a field from y=1572 to y=1319, so a
  coordinate captured one step earlier was already wrong. Every action
  re-resolves the element immediately before acting.
* A widget behind the keyboard reports its LAID-OUT position (1549) not its
  real one (1939) — a 390px error that taps a letter key and reports success.
  keyboard_is_up() distinguishes "absent" from "behind the keyboard", and tap
  closes the keyboard first.
* KEYCODE_BACK is a route pop in Flutter, not a keyboard dismiss: it navigated
  out of Settings and discarded typed credentials, twice. dismiss_keyboard
  tries ESCAPE then BACK and verifies via dumpsys input_method.
* `input text` inserts at the cursor, so typing over a filled field produced
  "canary@example.com321krzychu@gmail.com". Fields are cleared first.
* uiautomator returns ONLY the focused EditText nodes while a Flutter text
  field has focus, and stays that way across retries — so a naive retry loop
  never converges. Partial trees are retried and the best result kept.
* An EMPTY EditText has no text, content-desc or resource-id, so a
  "must be labelled" filter drops the very field you need to fill in. Editable
  nodes are kept regardless and addressable by position.

Ambiguity is an error rather than a silent first-match: `tap "Back"` correctly
refuses when "OFFLINE BACKUP" is also on screen.

Its own test suite caught a real bug in _escape: backslash sat inside the
escape loop, so "&" became "\\&" and a password containing shell
metacharacters would have been typed wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
2026-08-10 17:01:28 +02:00
590bc2004d ci: install jq for the shell tests, and probe for it explicitly
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The claude_usage i3blocks tests failed on CI while passing locally.
archlinux:base-devel ships no jq, and claude_usage.sh parses its cache
with it, so the block degraded to its "no data" path and every
percentage assertion failed. That degradation is correct at runtime —
the bar should not break because a tool is missing — but it made a
missing dependency look like a logic bug.

Install jq alongside git and strace, and probe for it at the top of the
section so the failure names its own cause. The probe runs jq rather
than calling `command -v`: a jq that exists but cannot execute produces
exactly the same "no data" output as an absent one, and `command -v`
passes in that case — verified by stubbing a jq that exits 127.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:40:42 +02:00
91061edc14 feedback: local-only pacman package build, and ignore build artifacts
fee[dB]ack has no AUR entry, so install_feedback.sh builds a local
package from the PKGBUILD tracked beside it, the same way
hypersomnia-appimage and jackify-bin are installed here. The script only
builds and then prints the `sudo pacman -U` line to run, since
interactive sudo prompts belong to the user rather than an agent.

The built .pkg.tar.zst is ~640 MB, so it is gitignored along with the
Bottles prefixes and the archwiki_rag vector index — all three are local
runtime state that is regenerable and must never enter the history (see
memories/binary-files.md). The PKGBUILD and the installer are the
source and stay tracked.

Exclude PKGBUILDs from shellcheck. A PKGBUILD is a makepkg manifest,
not a standalone script: it has no shebang by design, pkgname/pkgver/
source/sha512sums are read by makepkg, and pkgdir/srcdir are injected by
it, so shellcheck flags every one as unused or undefined. The same
reasoning already excludes zsh — the dialect is outside what shellcheck
can judge — and the alternative was a directive on nearly every line.

Also drops three stray files: a saved search-results page, a 0-byte file
from a mistyped redirect, and a June i3 config backup superseded by git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00
86565bdef6 archwiki_rag: offline semantic search over the Arch Wiki
Indexes the arch-wiki-docs dump that is already on disk and exposes it
to the knowledge-rag MCP server, so Arch questions can be asked by
symptom ("why doesn't my systemd user unit see DISPLAY under i3")
rather than by page title, with no network round-trip at query time.

The one Arch-specific MCP server in the wild fetches pages over the
network and bundles pacman operations that duplicate the local yay-mcp.
This does neither: it only searches, against a local corpus.

install.sh installs its own dependencies, wires the MCP config and
registers a pacman hook so the index follows package updates. The
generated index is machine-specific and regenerable, so it is
gitignored rather than committed.

_reindex_entry runs under the knowledge-rag interpreter, where
mcp_server is importable and this repo's packages are not. Resolving
that import with importlib.import_module states the deferral outright
instead of a function-level import statement that reads as an oversight,
and the backend failure path now re-raises as SystemExit, so the
exit-code translation is explicit rather than a swallowed exception.
Both replace lint suppressions; the package carries none.

pylint runs in its own venv, so beautifulsoup4 and markdownify are
declared there too — being in meta/requirements.txt does not make them
resolvable to the hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00
cef72fbab0 syncyomi_guard: refuse a collapsed payload as the new good copy
TachiyomiSY 1.13.2 can report a restore as SUCCESS while writing almost
nothing, then sync that near-empty library up and replace the only good
copy. On 2026-08-09 a 14 MB payload holding 2182 manga became a 267 KB
stub, and the real library survived only as stale pages in the SQLite
write-ahead log.

The upstream bug is Kotlin in an APK and cannot be fixed here, so this
targets the fixable half: decode the payload, count what is actually in
it, and refuse to treat a collapse as normal drift. A pure is-it-zero
check would have caught this incident but not the harder case upstream
describes, where a restore keeps some entries and drops the rest, so the
threshold is proportional. Categories get their own rule because all
2182 manga survived the round trip and all 18 categories did not.

Adds the setup script and a runbook covering the WAL-page salvage used
to recover the library on the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00
f33fd03a16 focus_owner: keep the unhide path reachable, never hide the enforcer
setApplicationHidden drops a package from the default PackageManager
enumeration even though it stays installed=true, so a sweep querying
with flags 0 could never see anything it had already hidden. That made
packagesToShow permanently empty: measured on device, a pass following
one that hid three apps reported "hid 0, restored 0" and left them
hidden with no way back. Query with MATCH_UNINSTALLED_PACKAGES instead.

Hiding the enforcer itself is the other unrecoverable case, since it
takes the escape hatch and the trigger with it. The exporter now injects
com.kuhy.focus_owner into both the day and night allowlists so the
decision layer cannot propose it, and the runner keeps its point-of-use
skip to cover a policy asset rendered before this fix or hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00
58c1df573c i3blocks: add Claude usage block, fix icon rendering, drop dead blocks
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Add a [claude_usage] block showing the 5h and 7d rate-limit windows. It
reads ~/.claude/cache/limit-state/*.json, which Claude Code's statusLine
hook already writes to disk, so the data costs nothing to display: the
script only runs jq against a local file and makes no API call. Limits
are account-wide while the cache is sharded one file per project, so it
takes the newest file and ignores cwd. Percentages arrive as floats and
are truncated; a stale cache is labelled rather than shown as current,
and a rolled-over window reads "?%" instead of a misleading value.

Replace Font Awesome private-use-area icons with plain Unicode. Both
fonts named in the bar's pango string ("System San Francisco Display",
"FontAwesome") are not installed, so PUA codepoints fell through to
whatever font claimed them: ethernet rendered as a star, wifi as
Cyrillic, gpu as Hangul, disk as a chess pawn. Emoji resolve through
fontconfig consistently, which the pre-existing volume block already
relied on, and need no change to ~/.config/i3/config.

Remove the bluetooth, battery, wifi, activitywatch, warp and
network_monitor blocks: this desktop has no BT adapter, no battery, no
wireless interface, and no warp-cli. This also removes a rendering bug,
since activitywatch_status.sh and warp_status.sh were interval=persist
yet emitted three lines per update, leaking their colour line into the
bar as literal "#FF0000" text.

Fix ethernet reporting "down" on a live wired link:
find_ethernet_interface returned the first non-loopback interface, which
with docker installed is a br-* bridge (permanently down, and sorted
before enp*). It now requires a real /sys/class/net/*/device and prefers
an interface that is up.

Tests drop the assertions and fixtures for the removed blocks and add
claude_usage coverage (fresh, warn, critical, stale, rolled-over, null
reset, unparsable, missing dir), a guard that removed blocks cannot
return, a repo-wide check that no block script uses a PUA codepoint, and
an ethernet physical-NIC regression check. The fork budget is asserted
against a populated cache directory, which caught a stat call inside the
scan loop that cost one fork per cached project (170 -> 2 execs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:14:34 +02:00
5d0caa9725 reverse_survivors: difficulty tiers
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
reverse-survivors CI / check (push) Has been cancelled
Haunting, Normal and Crusade scale enemy hp and speed, the survivor's
health, your income, and the length of the night. Normal is exactly 1.0 on
every multiplier — it is the identity tier, which is what lets every
existing raw-value assertion keep passing untouched.

Enemy speed now derives from a single helper, so difficulty and frenzy
compose in one place rather than fighting at three call sites.

Also fixes a bug this surfaced: handleRestart ignored the selected tier, so
restarting after choosing Crusade silently reverted to Normal. The
react-hooks purity rule flagged Date.now() in the new handler, and
extracting restartSeed() exposed the omission.

Endless mode is deliberately left out — fmtTime(Infinity) renders
Infinity:NaN, which would cost a guard plus tests for a mode whose only
content is the same game but longer.

README rewritten for all four features, including the reversal of its old
placement promise.

160 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
2026-08-09 23:17:07 +02:00
2d66bfdd61 reverse_survivors: director powers — ambush, frenzy, rift
Ambush drops a unit beside the survivor, inside the ring its kiting AI
would otherwise keep clear. Frenzy drives the whole horde faster and harder
for six seconds. Rift pins every spawn to one edge for ten, so you can mass
a flank instead of scattering.

This reverses the README's old promise that placement is never yours to
choose. The interesting decision turned out to be what a placement is
worth, not being denied one — so placement is purchasable, never free: a
premium for Ambush, a cooldown and a commitment window for Rift.

A shared spendPower gate covers cooldown and affordability for all three,
so the next power costs no new branches. EDGES became a Record keyed by a
finite union rather than an array: under noUncheckedIndexedAccess an array
index is possibly-undefined and would force an unreachable fallback.

The unpinned spawn path still draws the same RNG values in the same order,
verified against the old implementation across 50 seeds — existing seeded
replays are unchanged.

145 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
2026-08-09 23:10:38 +02:00
43b63ac266 reverse_survivors: survivor status effects (slow, suppress, bleed)
The warden mires, the artillery stifles, the leech unknits. All three need
contact, so a survivor that kites cleanly takes none of them — debuffs
punish positional failure rather than taxing existence.

isOn/factor are written as arithmetic rather than comparisons:
Math.min(1, Math.ceil(t)) is 1 while a timer is live and 0 once it expires,
so consumers multiply instead of branching. The whole status module
therefore contributes zero branches, which is what makes the 100% gate
affordable for a system this cross-cutting. Statuses are applied
unconditionally too; a 0-duration spec is a provable no-op.

applyStatus uses Math.max, so re-applying refreshes rather than stacks.
That caps a horde's total lockdown at one duration by construction — a
structural guarantee, not a tuned number.

123 tests, 100% coverage. Branches 117 -> 119: the draw halo gate and the
snapshot chip filter. The status logic itself adds none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
2026-08-09 23:08:51 +02:00
f0712d3854 reverse_survivors: add splitter, artillery, warden and the leech boss
Splitter dies into three rushers, so killing it creates pressure rather
than relieving it. Artillery outranges the survivor (460 vs its 260) and
holds station beyond reach, the first unit its kiting AI cannot answer.
Warden outruns it. Leech is a third boss.

Two new EnemySpec fields carry this: spawnKind generalizes the hivemind's
hardcoded 'rusher' minion, and splitCount drives on-death splitting.
split() is called unconditionally and is a no-op at splitCount 0 — a guard
branch would need two tests to satisfy the 100% gate and buys nothing.
Child offsets are fixed rather than drawn from the RNG, so seeded replays
are unchanged.

105 tests, 100% coverage. Branch count stays at 117: this adds none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
2026-08-09 23:06:20 +02:00
b0c7baf22e Log why Chromium browsers die; whitelist librewolf
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-09 22:27:16 +02:00
a96605dc4b Wire the enforcement decision to the DPM hide call
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The decision layer and setApplicationHidden were both built and tested but
never connected -- the service logged a tick. Joining them meant porting the
decision to Kotlin, because a background Service has no Flutter engine and
starting one per alarm to answer a pure question would be slow and would add a
failure mode at precisely the moment enforcement has to be reliable.

That duplication is only safe if the two cannot drift, so the Kotlin suite
restates the Dart cases one for one rather than testing something adjacent. It
was checked for vacuity by asserting the hidden set contained a package that is
not installed: 13 tests completed, 1 failed, and it was the right one.

Show is applied before hide. If a pass is interrupted -- process killed, device
sleeps -- the half that ran is the half that restores access rather than the
half that removes it. The launcher, the protected prefixes and the app's own
package are re-checked at the point of use as well as in the policy, because
hiding the dialer would be unrecoverable from the device itself. An unreadable
policy yields no decision rather than an empty allowlist, which would hide
every app. The next run is scheduled in a finally, so an enforcement bug costs
one tick instead of ending the chain.

Note for the next session: JDK 26 is the system default and breaks the Android
jlink transform; the Gradle tests need JAVA_HOME=/usr/lib/jvm/java-21-openjdk,
which is what Flutter itself uses.

NOT verified on device. Unit tests cover the decision, but no real hide/show
pass has been observed -- a managed profile was created and the app made
profile owner, and the UI confirmed the policy loaded, but the session ended
before a pass was triggered. The profile was removed and user 0 verified clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 22:00:52 +02:00
cc804dd311 Run enforcement from exact alarms, and re-arm it before first unlock
The decision layer needed a runtime. A polling loop was the obvious design and
the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00
boundary leaves the curfew allowlist applied into the morning -- the user finds
their banking app missing with no obvious cause. The transitions are known
ahead of time, so they get exact alarms. The service wakes, applies, schedules
the next run and stops rather than sitting resident.

BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the
latter does not arrive until first unlock. That is not theoretical: this phone
sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with
no network and no app able to start. Relying on BOOT_COMPLETED alone freezes
enforcement state across a reboot -- safe for hiding, not for unhiding, which is
the direction that strands you without a dialer. The schedule is re-armed
before unlock; the pass itself waits until storage is readable.

The first on-device run logged "exact alarms not permitted", and dumpsys showed
what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute
fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and
denied by default on Android 16; USE_EXACT_ALARM is the permission for apps
whose function is acting at specific times, and is granted at install. After
the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to
maxWhenElapsed.

The service reports "not DO/PO - no enforcement applied" and does nothing when
unprovisioned, rather than failing quietly, and a failure inside a pass cannot
stop the next run being scheduled -- an enforcement bug should degrade one tick,
not end the chain.

Not yet wired: the service logs a tick rather than calling setApplicationHidden
with the decision. Both halves are tested; joining them is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:50:58 +02:00
d0fcda3a47 Provision home coordinates out of git; layer the workout signal; drop the test hook
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The geofence needs HOME_LAT/HOME_LON, but those say where I live, so the
committed policy asset is generated with --redact-home and the real values are
pushed to app-private storage instead. push_home_location.sh greps
config_secrets.sh rather than sourcing it -- that file holds other secrets and
this script has no business evaluating them -- and refuses the shipped
REDACTED_* placeholder with a message naming the file to fill in, rather than
writing whatever the parse happened to land on.

Private storage was chosen for its lifetime as much as its permissions: it does
not survive an uninstall, so reinstalling forces re-provisioning instead of
silently inheriting a stale home. The default umask left the file 666, so the
script chmods it 600; the shell user gets Permission denied, and the
/data/local/tmp staging copy is removed after the transfer.

Every read failure returns null rather than throwing. Null reaches the
enforcement layer as location-unknown, which already fails closed into
enforcing -- so a corrupt coordinates file cannot become an unlock, and cannot
crash the service either.

Workout detection replaces a root-only SQLite read of StrongLifts' private
database. The manual source is time-boxed rather than a plain toggle, because a
workout mode left on by accident is indistinguishable from having no exception
at all. The guarded wrapper catches broadly and times out: nothing about a
workout probe failing should be able to stop enforcement running. The combined
source can name which signal opened the exception, so the UI can explain an
unlock rather than presenting it as magic. Only the two sources verified to
exist on this device will be wired -- com.kuhy.workout_app exposes nothing
readable and org.runnerup is not installed.

HideTestReceiver is deleted. It proved setApplicationHidden persistence, which
is what it was for, but it applied policy from an exported receiver and that has
no place in a build that will later hold device owner. Confirmed absent from the
rebuilt and reinstalled APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:42:20 +02:00
1b7958143d Prove DPM app-hiding survives a reboot; build the enforcement decision layer
The whole Device Owner design assumed setApplicationHidden persists across a
reboot, and that had never been tested -- while both shell primitives were
measured to be cleared by one. Settling it looked like it needed a factory
reset. It does not: a managed profile is removable and takes its profile-owner
grant with it, so the DPM API can be exercised and then erased.

  pm create-user --profileOf 0 --managed   -> user 10
  dpm set-profile-owner --user 10 ...
  ... hide, reboot, re-check ...
  pm remove-user 10

hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the
DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome
in user 0 untouched.

Scope limit, stated because it is easy to overclaim: this proves the API's
hidden state persists under PROFILE OWNER inside a work profile. It does not
prove a DEVICE OWNER can hide a given app in user 0, which is what the design
actually needs -- DO there additionally cannot touch the launcher, the default
dialer/SMS/IME or packages the system deems critical, and the call can return
false silently. That must be tested in the first minute after provisioning,
while clearDeviceOwnerApp() is still a cheap exit.

On top of that, EnforcementDecision turns policy plus conditions into a
hide/show set. It is a pure function, so the whole decision layer is tested
without a device, a clock or a location provider.

packagesToShow is deliberately the complete allowed set rather than a delta.
Failing to hide something is a mild disappointment; failing to UNHIDE strands
the user without a dialer or a bank at 09:00 because the phone rebooted during
curfew. Applying the full set on every evaluation makes a missed run
self-repairing, which matters because BOOT_COMPLETED does not fire until first
unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after
a reboot with no network at all.

The workout exception is expressed as packages rather than domains. The rooted
system unblocked youtube.com in a hosts file; hiding an app cannot target a
domain, so the package set is derived from the configured domains and config.sh
stays the source of truth for intent.

Signal sources were checked on the device before being built: StrongLifts is
granted health.WRITE_EXERCISE and Health Connect is installed, so that path is
viable; com.kuhy.workout_app exposes only a MainActivity and Flutter
boilerplate; org.runnerup is not installed here at all. Only the two that can
actually produce a signal will be wired.

HideTestReceiver is temporary and must be deleted before any provisioned build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:36:26 +02:00
7cfe188bbe Fix engine pruning: disabled -> inactive (they were still running)
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-09 17:56:59 +02:00
dc45779d38 Prune 84 SearXNG engines; images category now sub-1s
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-09 17:42:56 +02:00
84d1c6e897 Record browser CSP verification for SearXNG
Closes the one gap left open at deploy time. The strict CSP
(default-src 'none') had only been validated by static asset analysis,
because headless chromium produced empty output on this host and the
browser extension was not connected at the time.

Loaded the live instance in a real browser: front page, text search,
image search and autocomplete all work, and the UNFILTERED console shows
4 messages total -- all benign [PLUGIN] calculator DEBUG lines from
SearXNG's own bundle, zero "Refused to load" CSP violations. The "?"
tiles in image results are upstream 404s, not blocked requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG
2026-08-09 17:26:28 +02:00
bd725b21b7 app_icons: add a decision-tree glyph and register untools
The new ~/untools app (guided thinking tools) needs a family launcher icon
rather than the stock Flutter beach one.

The glyph is one node branching into two -- "decompose a problem into parts",
which is the move every one of the app's 25 frameworks makes in some form.
Nodes are hollow rings rather than filled dots so the 72px stroke leaves
visible interior at icon size.

A two-level, seven-node tree was drawn first and rejected on the generator's
own evidence: it reported the glyph overflowing the 560px safe box by
~170x162px, and rendering confirmed the doubled connectors closed up into a
solid mass at launcher size. One clean split survives the mask and still reads
as a hierarchy. Branch spread was then narrowed to clear a residual 44px.

linux=False for this app: its desktop build is the web build in a Chrome
--app window, so there is no GTK app to hand a hicolor PNG to.

Also whitelists com.kuhy.untools in phone_focus_mode/config.sh. That entry is
source-of-truth only -- the current Pixel 6a is not rooted, has no
/data/local/tmp/focus_mode and no running daemon, so nothing was pushed to the
device and no app is being killed there. Kept for the rooted BL9000 phone.

Verified: 44 app_icons tests pass with the package at 100% coverage; the
generator emits 6 assets with no safe-box warning; the rendered icon was
inspected and matches the family; and the resulting APK installed and stayed
alive on 23181JEGR08034.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hVZ5HfcrcrVzinmjQuQup
2026-08-09 17:23:35 +02:00
8f2c46de7c Add setup_searxng.sh: self-hosted SearXNG at searx.kuhy.duckdns.org
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-09 17:21:51 +02:00
82e936e62b app_icons: add the kuhylog app and its track-bars glyph
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Three rising bars with a plotted point above the tallest: a day-by-day log
that adds up to a trend, which is what the app tracks. Bars are strokes
rather than filled rects so the glyph keeps one stroke weight, and the
168px pitch leaves 96px gaps -- comfortably above MIN_NEGATIVE_SPACE, which
is where these glyphs usually fail.

kuhylog has a Linux desktop target as well as Android, so it is registered
with linux=True and gets the hicolor set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQtqicQHSrvBa4XaNNh9Wy
2026-08-09 15:26:55 +02:00
137fc99a2e app_icons: add the anvil glyph and register octoforge
OctoForge (~/octoforge) is app #10 in the shared launcher-icon family. It was
still shipping the stock Flutter icon, which is not a finished state.

The glyph is a filled silhouette rather than a stroked outline: a stroked
anvil is a long thin horn plus a narrow waist, and at the family's 72px stroke
weight the waist closes up solid at 48dp. An octopus (GitHub trade dress, and
a tangle of thin curves that blurs at launcher size) and a bare hammer (reads
as a generic tool, not a forge) were both rejected.

The horn is a separate wedge projecting off the face, not a rounded corner on
it. Two earlier drafts folded the taper into the slab's left end and both
rendered as a bump, leaving the shape reading as an I-beam. It now tapers to a
40px blunt tip, above MIN_NEGATIVE_SPACE so it survives at 48dp.

Registered with linux=True: unlike the other phone-first apps this one has a
real desktop target with a .desktop entry, so it needs the hicolor set.

Verified: generated for Android and Linux, checked against the family preview
sheet at all three sizes, and confirmed in the device app drawer next to todo.

Committed from a temporary worktree: the working tree held another session's
in-flight kuhylog entry in the same hunk, and git-rules.md forbids stashing
around other agents' uncommitted work. That work is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzEA5srk8VPn6mKKjCaGpb
2026-08-09 15:24:09 +02:00
143d260c1c Park phone DNS filtering: Private DNS fails closed, so the PC cannot host it
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
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
2026-08-05 10:05:16 +02:00
e3d5e1dd18 Serve the blocklist over DoT and hand the policy to the app as an asset
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
2026-08-05 09:36:11 +02:00
f636d2b21b Make focus policy and blocklist usable without root; fix workout CDN unblock
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
2026-08-05 09:15:02 +02:00
f1590cbb64 Pin prettier, shfmt and zsh too, not just shellcheck
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-04 21:07:49 +02:00
23fdfcf867 Pin shellcheck in CI so local and CI agree on what a finding is
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
2026-08-04 21:01:01 +02:00
9dd9aeb50e Fix the 11 shellcheck findings that kept CI red since b83ce5d
"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
2026-08-04 20:55:02 +02:00
1b43ba093c mtk_root: build the rooting toolkit before the phone lands
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-04 20:38:44 +02:00
b83ce5d3c7 Fail the build on any shellcheck finding; fix the 222 the old gate hid
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled
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>
2026-08-03 19:49:11 +02:00
79829ec8a2 Diagnose the pacman first-hook stall; fix the drift that hid a stale wrapper
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>
2026-08-03 15:15:34 +02:00
4d9f486176 billsplit: build a real APK by dropping file_picker for file_selector
billsplit had never produced an Android APK. file_picker 11.0.3 — the latest
release on pub.dev — is wedged under AGP 9 in both directions: it skips
applying the Kotlin Gradle Plugin when AGP >= 9, so with built-in Kotlin off
its own Kotlin never compiles (`cannot find symbol: FilePickerPlugin`), and
with built-in Kotlin on its buildscript leaks KGP onto the global classpath
via rootProject.allprojects, which AGP then rejects for every module.

The app's Gradle config was never at fault: android/app/build.gradle.kts and
settings.gradle.kts are byte-identical to habit_stack, home_inventory and
diet_guard_app, which all build. Only the dependency differed, so the fix is
the dependency — the toolchain stays on the house-standard AGP 9.

Ported both call sites to the official file_selector. Its save flow returns a
destination and leaves the write to the caller, where file_picker wrote the
bytes inside the plugin; that write now goes through an injectable seam
because a real dart:io future never completes inside testWidgets' fake-async
zone, so a widget test could otherwise never observe the resulting snackbar.
The test double moves from a MethodChannel mock to a FileSelectorPlatform fake
(file_selector speaks Pigeon, so a channel mock would not intercept it), and a
plain non-widget test covers the real on-disk writer to hold 100% coverage
without an ignore.

The launcher icon is now verified from the built artifact: the ic_launcher
assets unzipped from the APK render as the charcoal + gold perforated receipt,
not the stock Flutter beach. Peak build footprint is 1.86 GiB, inside budget.

Also rewrites ci_mirror.sh to run its gates in a disposable worktree at HEAD.
`pre-commit run --all-files` stashes the live tree, and in a shared checkout
that made an unrelated agent's uncommitted work collide with hook auto-fixes,
failing the push while blaming an innocent hook — and putting that work inside
a stash window, which memories/git-rules.md forbids. A clean worktree needs no
stash and is a truer mirror of CI, which never sees a dirty file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:15:40 +02:00
287512f011 phone_focus_mode: reach the phone over adb without touching it
Android's Wireless debugging is deliberately ephemeral: the listener dies
when you leave the settings screen or the device sleeps, and it picks a
new random port each time. In one session it offered 35545, then 40401,
then 43781, and vanished entirely twice. Every deploy therefore started
with tapping through Settings and reading a port off the screen.

enable_persistent_adb.sh waits for any such transient window, then uses it
to pin adbd to port 5555 - the port deploy.sh has always assumed - and
installs a Magisk service.d boot script so the same happens on every boot.

The phone is on DHCP with no reservation, so the script identifies it by
MAC rather than trusting a hardcoded address: a ping sweep populates the
neighbour table and the address is looked up there. That beats a router
reservation because it needs no router access and works on any network.

One bug worth naming: the first version took the first `device` line from
`adb devices`. Pairing also leaves an mDNS _adb-tls-connect._tcp entry for
the same phone, so it reported port 5555 as reachable while nmap showed it
closed. It now matches the exact ip:port.

Verified: nmap showed 5555 closed then open across the switch; a run
against a deliberately wrong IP reported "phone moved" and connected; and
after `adb reboot` the script connected with no arguments and no
interaction, with /proc/uptime at 149s and service.adb.tcp.port reading
5555, so the boot script really ran. shellcheck clean.

Trade-off accepted by the user: adbd on a fixed port is reachable to
anything on the LAN, though it still requires an authorised key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:33:45 +02:00
ed1a224ce7 Format last three markdown files; cap billsplit's Android build memory
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>
2026-08-03 13:25:48 +02:00
5b654fc286 app_icons: finish the epopeja_karta entry; whitelist it on the phone
Registers the new Epopeja character-sheet app (~/epopeja_karta, its own
repo at kuhyx/epopeja_karta) in the shared icon family and lets it run
under focus mode.

The quill-nib glyph and the initial APPS entry already landed in c600760
("Move billsplit Flutter app into the monorepo"), which picked them up
from the working tree while they were still in progress. This commit adds
what was still outstanding:

- phone_focus_mode/config.sh: whitelist com.kuhy.epopeja_karta, without
  which focus mode kills it within ~1s at home. Not deployed to the device
  yet, so config and phone are out of sync until deploy.sh runs.
- apps.py: correct the epopeja_karta comment. It claimed there was no
  desktop target; a linux/ target was since added so the app can be driven
  on the desktop during development. linux stays False - the app ships no
  desktop entry and gets no hicolor set.
- The evidence and contract artifacts the pre-commit hooks require.

Verified: 44 app_icons tests pass, 100% branch coverage on the package,
safe_box_overflow for quill-nib is (0, 0), and the generated mipmaps show
the nib rather than the stock Flutter icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:41:13 +02:00
c600760e71 Move billsplit Flutter app into the monorepo
billsplit was a standalone Flutter app in ~/Downloads with one commit and no
git remote — that directory was the only copy of its history.

Three things the move needed beyond copying the tree:

- The root .gitignore silently swallowed 32 of its files (*.png, *.ico,
  *.manifest). Ignored files do not error on `git add`, they just vanish, so
  this would have committed an app that cannot build. Added a trailing block
  of ! overrides plus .binary-allowlist (which did not exist yet) — the
  documented in-repo exception for binaries a build genuinely needs. The
  ../testsAndMisc_binaries/ symlink scheme does not apply: those directories
  are gone, and a symlinked PNG breaks a fresh clone.

- billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads
  workflows at the repository root. Relocated with a working-directory and a
  billsplit/** path filter.

- The app's coverage gate moved to python_pkg/billsplit_coverage/ so every
  Python file stays under python_pkg/, and gained a test suite at 100% branch
  coverage as that location requires. billsplit/ now holds only Dart.

Also replaces the stock Flutter icon with a family icon from the shared
generator. The glyph is a filled silhouette because the family's 72px stroke
cannot carry a fine torn edge, and the split is an evenodd-cut perforation so
the holes stay transparent on the foreground and monochrome layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:22:22 +02:00
2b3c1a69f7 fix(hooks): make git hooks and their toolchain mandatory and fail-closed
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>
2026-08-03 11:09:11 +02:00
a47191673a feat(reverse_survivors): migrate Reverse Survivors game into the monorepo
Moves the standalone React 19 + Vite 8 + TypeScript 6 game from
~/Downloads/reverse/reverse-survivors into reverse_survivors/, so it is
version-controlled, indexed, and covered by CI instead of sitting untracked
outside any repository.

- Relocates the project's nested .github/workflows/ci.yml to the repository
  root as .github/workflows/reverse-survivors-ci.yml. GitHub only reads
  workflows from the root, so the nested copy would have been inert — same
  treatment billsplit received. The workflow is path-scoped to
  reverse_survivors/** and runs npm ci / check / build on Node 22.
- Sets cache-dependency-path on actions/setup-node, since
  defaults.run.working-directory applies only to run: steps and not to
  actions; without it the npm cache looks for a lockfile at the root.
- Keeps npm and the existing package-lock.json. TypeScript stays pinned at
  6.0.3 deliberately: typescript-eslint 8.65 caps its peer range at <6.1.0
  and TS 7 drops the JS compiler API that typed linting needs.
- Adds a reverse_survivors/ row to the Repository Layout table in README.md
  and CLAUDE.md.

Verified in place: npm ci, npm run check (95 tests, 100% statements /
branches / functions / lines), npm run build, and a dev-server smoke check
serving /src/main.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:45:12 +02:00
8e24ff093f Add boot-repair: offline recovery for a stale EFI System Partition
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>
2026-07-30 12:48:56 +02:00
89ed9bc95d wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
New package. Walks every thread on 4chan's /wsg/, downloads each video it has
not seen, and reviews them one at a time: mpv embedded in a Tk window via --wid
with Keep/Pass/Undo directly beneath the picture.

Dedupe is structural rather than a convention. Every post carrying a file also
carries md5 -- base64 MD5 of the whole file -- so it is the sqlite primary key.
An already-seen video costs zero bytes to skip and a byte-identical repost in
another thread collapses onto the existing row. Verified against a live file:
the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.

Undo is unbounded and survives quitting: the verdict trail lives in the index,
not in memory, so yesterday's decisions can still be taken back.

No downloaded video is ever deleted. A pass moves the file to trash/ and leaves
it there; files.apply_move refuses to overwrite an existing destination, so the
guarantee holds by construction rather than per caller.

Three bugs only running it could find, each caught from evidence rather than
guesswork:
  - the video stayed black: a <Configure> handler set mpv's geometry, fighting
    the automatic sizing --wid already does
  - playback never advanced: refresh() ran every tick and re-sent loadfile ~7x
    a second, restarting the video continuously
  - it froze on one video around the 26th while filenames kept advancing: mpv
    pushes an event stream down the same socket and nothing drained it, so the
    buffer filled and mpv stopped reading commands. Isolated to command #35
    without a drain thread; all 60 honoured with one.

A five-lens review then found nine more, all reproduced before fixing: a failed
mpv start stranded the non-daemon worker forever; undo could clobber a file in
incoming/; a NUL-bearing md5 poisoned the index permanently; an oversized int
killed the worker with no event; gzip bombs on both hosts were accepted; the
board endpoints were fetched unconditionally forever; and Session.missing could
corrupt the undo trail.

mpv runs as a subprocess over its JSON IPC socket rather than via python-mpv,
which resolves libmpv at import time and would fail on CI while passing here.
Third-party surface is requests alone, already declared.

277 package tests, 100% branch coverage on all 22 modules, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:55:30 +02:00
0fc3788d7a app_icons: home_inventory has a Linux desktop target
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Its desktop app is a Chrome wrapper around the Flutter web build, and it
ships a .desktop entry plus a hicolor icon theme -- so it genuinely has a
Linux target. With linux=False, `generate --linux-out` skipped it silently
and reported success, which meant desktop/icons/ in that repo could not be
regenerated at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:26:26 +02:00
45f4311993 app_icons: add home_inventory storage-box glyph; whitelist it on the phone
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The new ~/home_inventory Flutter app needs two things from this repo: a
family launcher icon, and a focus-mode whitelist entry so the daemon does
not pm disable-user it ~1s after launch.

The glyph is an isometric carton — a hexagon plus the three edges meeting
at the near-top corner. A front-on rectangle with a lid seam was tried
first and rejected on the contact sheet: a seam plus a divider reads as a
browser window, not a box.

Body is 488x440, not a rounder 500x440, because STROKE_WIDTH straddles the
path and adds 72px to each rendered dimension; 488 is the widest body whose
ink still fits SAFE_BOX. The generator warned about exactly this at 500.

Accent stays the one shared family accent — apps differ by glyph, not
colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:35:55 +02:00
9ab005f18f Point the hook test at the extracted session_autopsy, add the artifacts
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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>
2026-07-26 20:50:44 +02:00
db9c8921a2 kcd2 dice solver: lock deploy_build.sh so two runs cannot publish half a bundle
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
deploy_build.sh has two entry points — setup_kcd2_dice_solver.sh calls it
directly, and the post-commit hook starts a unit that runs it — and nothing
serialised them.

When they overlap, the second run's `vite build --emptyOutDir` truncates
dist.staging while the first run's rsync is still reading it. rsync exits 24,
`set -e` aborts before assert_mount_live, and dist/ is left holding a fresh
index.html that references assets which never arrived, with the previous
bundle's files already deleted by --delete-after.

Nothing downstream notices, which is what makes it worth fixing rather than
tolerating: `try_files {path} /index.html` answers the missing chunks with the
SPA HTML, so the browser is handed HTML where it expects JavaScript and no 404
reaches any log (verified against the live site: /assets/does-not-exist.js
returns 200 text/html). The hook's `|| true` then swallows the unit failure, so
the commit succeeds and the site is quietly broken.

Reproduced with a throttled rsync: rc=24, live left with index.html and two of
six chunks. With flock, two concurrent runs each publish a complete bundle.

Also tightened assert_mount_live, which announced success in three cases where
it had verified nothing: docker absent, container not running, and a failed
`docker exec` — the last because docker reports OCI runtime errors on stdout,
so `2>/dev/null` does not suppress them and the guard's non-empty test passed
on a 184-byte error string. It now requires an actual inode and says plainly
when the mount was not verified.

Found by the working-diff review these same changes had skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:33:38 +02:00
9d7751cebb kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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>
2026-07-26 20:01:21 +02:00
5ffd035c21 session-autopsy: measure turn efficiency, the dominant token cost
Cache read is charged per API round-trip on the whole conversation so far, so a
long session's cost is driven by TURN COUNT, not message length. One measured
session spent 267M cache-read against 856k output — a ratio of 290:1. The report
priced tokens but never counted the turns spending them.

Across the 263 stored transcripts this now shows 33,508 tool turns at an 8%
batching rate, of which 4,437 (13%) decided nothing: polling a counter, running
a linter, checking whether a push landed. At 606k average context re-read per
turn that is ~2.7B cache-read tokens on turns a script could have taken.

- turns.py classifies a command as poll / lint_test / vcs_check / substantive,
  deliberately conservative: state-changing commands (git commit, push) count as
  substantive even though they are scriptable, so the reported waste is a floor.
  A turn is only mechanical when EVERY command in it is.
- Batching is grouped by requestId, not by transcript line: one API response can
  span several lines, and counting per line reports every turn as single-tool
  (0% batching where the real rate is 12%).
- records.py gains TurnEfficiency (schema v3, additive — old records read as
  zeros) with derived rates written into to_dict so readers need not recompute.
- report.py gains a "Turn efficiency" section pricing each mechanical kind.
- _TurnTracker owns both the totals and the open turn, after pylint flagged
  _Accumulator over its attribute budget; _Accumulator.turns is now a property,
  so every existing caller is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:29:25 +02:00
071963b92b Fix: stop the Flutter SDK reading your own repo as itself inside git hooks
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
`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>
2026-07-26 15:39:33 +02:00
d91c0ab0e9 fix(i3): repair the dead idle-inhibitor autostart and make it a singleton
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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>
2026-07-25 19:57:57 +02:00
5ab9a806eb session_autopsy: handled candidates leave the nudge; drop explain-diff
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled
Per user verdicts: the ⚙ statusline glyph told them nothing (removed on
the ~/.claude side; the plain-words SessionStart line is the channel) and
explain-diff is not wanted at all (skill deleted there, verdict 'dropped').
The count now means 'significant AND not yet handled' — resolving a
candidate visibly shrinks it (7 -> 4), and handled table rows stop
re-suggesting a review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:12:38 +02:00
a07405c05f session_autopsy: badge counts only significant candidates; zero-token aliases
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>
2026-07-25 18:05:49 +02:00
7f706adb11 Make the hook tests' non-vacuity re-verifiable, and gate it
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
63a5124 claimed "12 tests, 12 killed by at least one mutation", but the harness
that proved it lived in a session scratchpad. That left an unfalsifiable claim
about tests whose entire purpose is to not be vacuous -- and the facts it
encodes are exactly the ones a future edit to either hook would invalidate.

The mutations are per guard, not one whole-body gutting. Gutting a hook body
kills only the three positive tests, because a gutted hook emits neither a
launch nor any output, which is precisely what the nine negative tests expect.
Run that way the negative tests look vacuous when they are not; dropping each
guard in turn is what actually exercises them.

Two failure modes of the harness itself are handled. A mutation that leaves the
hook text unchanged now raises instead of quietly reporting "killed 0": a
behaviour-preserving edit to a hook, such as renaming a variable, would
otherwise turn a mutation into a no-op and weaken the proof without a word.
Verified by renaming count to n in a copy of the start hook -- the copy still
printed the correct banner, and the tool still refused it. And where the hooks
are not deployed at all the tool exits 0 rather than failing, since a CI runner
has no ~/.claude to mutate.

Wired into pre-commit scoped to the suite and the mutation definitions, the only
two files that can invalidate the proof. It costs ~40s, so it stays off
unrelated commits. It gates rather than warns: survivors mean exit 1.

One finding stands recorded: start-no-readable-guard kills nothing on its own.
The start hook's [[ ! -r "$STATE" ]] guard is redundant, since jq's stderr is
suppressed and an empty count already fails the arithmetic, so only the compound
mutation reaches those cases. The guard is left in place as defence in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:37:34 +02:00
63a5124920 session_autopsy: test the hooks, the untested half of the analyzer
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>
2026-07-25 17:27:30 +02:00
a83b5abc27 python_pkg: track session_autopsy, finishing its interrupted refactor
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The transcript analyzer behind /compile-candidate lived only in the working
tree. Untracked meant unlinted — pre-commit only inspects files handed to it —
so nothing had ever checked it, and it had been abandoned mid-refactor with
every parse raising AttributeError: '_Accumulator' object has no attribute
'first_timestamp'.

The accumulator had been moved onto the grouped records dataclasses, but three
increment sites and parse_session still read flat attributes that no longer
existed. parse_session now hands over acc.meta and acc.counts directly; only
subagent_count comes from elsewhere, since no transcript line carries it.

Two modules broke the 500-line limit, so they were split along seams that were
already there. signatures.py takes the text-signature helpers — traces.py was
importing them from parse.py, making parse an accidental hub, and now neither
imports the other. stats.py takes the pure number and time helpers. parse.py
521 -> 458, detectors.py 559 -> 467.

The structural lint findings were refactored, not configured away.
_counter_candidates took eight arguments; the five naming which counter field to
mine and at what thresholds became a frozen _CounterSpec, so both call sites are
one line. _extract_skill carried in_span and a line budget as loop variables
across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at
four. record() took twelve flat kwargs and now takes one override per record
block, with session_meta() so a partial meta override keeps the standard
timestamp — a bare SessionMeta(slug=...) would have nulled started_at and
quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST
pass. tests/factories.py moved into conftest.py, where every other package here
keeps shared builders and where name-tests-test does not object.

965 tests pass at 100% statement and branch coverage, every hook is green, and
no suppression was added anywhere. `candidates` and `traces skill-finish` both
run against the real corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:56:43 +02:00
11ff8e6a26 kcd2_dice_solver: Balatro's joker replaces the one, confirmed
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The mapping was marked UNVERIFIED because no published source states which pip
the grinning face covers. The player, who owns the game, confirmed it is the one
— the same face the Devil's head die replaces. So the die carries no plain single
pip at all, which is exactly why picking the joker alone counts as a 1.

Comment and README only; wildcardFaces was already [1]. The uniform side weights
stay flagged as the one remaining assumption, since nothing publishes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:33:08 +02:00
1b0a8dff46 python_pkg: the file-length test was blind to its own CI venv
It walked the working tree and skipped vendored trees by hard-coded directory
name. `.venv` was listed; `.ci-mirror-venv` — created inside the repo by the
pre-push CI mirror — was not, so the walk descended into it and died with a
UnicodeDecodeError on a joblib fixture that is deliberately encoded big5. A
crash, not a report: every real violation was hidden behind it.

Venvs are now found by their pyvenv.cfg marker, so any venv is pruned whatever
it is named, and the walk prunes in place instead of visiting thousands of
third-party files to filter them afterwards. Lines are counted from bytes, so a
line count cannot depend on an encoding. third_party/ joins the vendored skip
set — it holds upstream MCP servers, which this repo's 500-line rule never
governed.

Two regression tests come with it: a venv under an unlisted name holding an
oversized, undecodable module is pruned, and the line counter handles big5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:32:32 +02:00
4aae92ae09 kcd2_dice_solver: Balatro's die has one joker face, not six
The wiki's dice-effect row for Balatro's die is `{{Dice effect|d|d|d|d|d|d}}`
— six *unfilled placeholder* cells that render as "d%". A previous pass read
those six `d`s as six wildcard faces, which made the die resolve every throw in
the player's favour: EV 8000, bust 0%, and the solver forced it into every
loadout it could. Only one face is the joker (Jimbo's grinning face, visible on
the item icon), and no source publishes the die's side weights at all.

The two joker faces also do not score alike, and the scorer was treating them as
one thing. Per the wiki, a Devil's head "matches any combination but never
scores on its own", while Balatro's joker counts as a 1 when picked alone. So
there are now two substitute slots: a Devil's head is spent inside the
combination it completes rather than resolved to a plain face, which is what
stops it being held as a lone 1. Hands with no substitute keep the original
single-pass code path.

Balatro's die: EV 8000 -> 771.2, bust 0% -> 3.09%; a lone devil's head scores
100 -> 0 and is no longer a legal hold. The two dice now tie exactly on a single
throw of six and differ only mid-turn (813.7 vs 808.3 points per turn), which is
where "may be held alone" actually pays.

Which pip the joker replaces is unpublished; the 1 is assumed, matching the
Devil's head die the game compares it to, and marked UNVERIFIED in the data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:14:04 +02:00
4f2c82fee7 Add overnight power-save to night-lockdown; harden dnsmasq with a watchdog
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
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>
2026-07-23 11:58:37 +02:00
a7596d0442 bucket_catch: introduce a real design-token system, fix reversed gradients
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Fixes every violation in DESIGN_AUDIT_TODO.md's bucket_catch frontend
section. The app previously had no shared tokens at all (index.css was a
12-line reset) and 19 hardcoded hex colors duplicated independently
across 6 *.module.css files.

New :root token block in index.css: bg gradient stops, a drag-state
gradient, the indigo/pink accent pair collapsed to one canonical
--gradient-accent direction, text/surface colors, success/error, 2
consolidated muted-gray roles (down from 4 unrelated shades), a 4px
spacing scale, and a radius scale. All 6 component files migrated onto it.

Fixes the real reversed-gradient-stop bug the audit named
(PuzzleResult.module.css .grade was pink->indigo vs every other heading's
indigo->pink) plus a second instance of the same bug the audit's prose
didn't cite by line number (ModeSelect.module.css .puzzleStart button) --
both now reference --gradient-accent so this class of bug can't recur.
Also fixes: .preset's border contrast (was under 3:1), cardDesc's
sub-16px font-size, 4 buttons' off-ratio padding (now exact 2x), 2 large
icons' full-contrast rendering next to muted text (now dimmed), the
explicitly-cited off-grid spacing values, and ModeSelect's gridInput
background color which had drifted from its DropZone sibling's.

Verified: 145/145 tests at 100% coverage (CSS-only, confirms no
functional regression), a real vite build succeeds, re-verified identical
in an isolated worktree at HEAD, and pixel-checked the production build
via a headless-Chrome screenshot (gradient direction now matches between
heading and button; preset chip borders clearly visible).
`npm run lint` (tsc -b + eslint) was already broken on a clean checkout
before this change (1 pre-existing tsc error in vite.config.ts, 46
pre-existing eslint errors in untouched test/lib files) -- confirmed via
an isolated worktree at the parent commit, left alone as out of scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 19:18:12 +02:00
27456c431a chore: fix prettier formatting on the kcd2 evidence JSON
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:05:04 +02:00
517fbe5d3b kcd2_dice_solver: warm-tint the unified-design-system palette, fix design audit violations
Fixes every violation in DESIGN_AUDIT_TODO.md's kcd2_dice_solver section:
15px root font-size (root cause of both the sub-16px body-text floor and
the off-grid rem spacing scale), 2 hardcoded tier colors bypassing the
custom-property system (also separates their brightness from --accent/
--good, and removes an accidental cool tint from tier-silver), the
largest text widening tracking instead of tightening it, near-invisible
border contrast (~1.4:1 -> >=3:1 against both bg and panel), inconsistent
ad-hoc border-radii collapsed into a 2-step scale with nested elements
using the smaller radius, and two off-ratio button paddings corrected to
an exact 2x.

Verified: lint clean, 210/210 tests at 100% coverage, production build,
re-verified identical in an isolated worktree at HEAD (narrowly staged
around this monorepo's unrelated dirty state), and pixel-checked via a
real `vite preview` production build through a headless-Chrome screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:03:45 +02:00
7f2c1e19df app_icons: auto-sync pubspec.yaml's adaptive_icon_background
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled
style.BACKGROUND changing doesn't help if flutter_launcher_icons reads a
stale value from pubspec.yaml's own independent config -- that's exactly
what just happened across all 6 apps during the warm-palette rollout: the
generated PNG assets were correct, but Android's actual home-screen icon
(the adaptive icon: background color + foreground drawable, not the
legacy ic_launcher.png mipmap) stayed on the old color, invisible until a
physical device check caught it.

sync_pubspec_background() now rewrites the line in place before every
run_flutter_launcher_icons() call, so this can't silently drift again.

Verified: 43/43 tests (4 new), 100% line+branch coverage on generate.py.
2026-07-21 21:38:22 +02:00
f1320e1f7e app_icons: warm-tint the shared icon family palette
Charcoal field #1B1D21 -> #211D1B, accent #4C7DF0 -> #B8862E across all 6
apps in the family, matching the unified-design-system palette rework
already applied to diet-guard/dufs-cloud/screen-locker/gatelock. Both are
exact byte-reversed mirrors of the old values, so relative lightness is
unchanged -- only the tint shifts from blue toward red/brown.

Also fixes stale prose in README.md left over from an earlier accent-
unification pass (claimed each app had its own accent; they've shared one
since that pass).

Verified: 39/39 tests, preview contact sheet reviewed, all 6 repos'
assets regenerated (diet-guard's rebuilt APK pixel-verified on device).
2026-07-21 21:20:41 +02:00
88dabb08b2 Apply prettier formatting to new kcd2_dice_solver files
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 18:17:45 +02:00
3babda92d2 Track kcd2_dice_solver app and add run.sh dependency installer
The Vite/React/TS dice solver had no git history yet. Commit it alongside
a run.sh that ensures Node/pnpm are present, installs deps, and starts
the dev server with a single command.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 18:17:13 +02:00
ae458d92cf app_icons: register habit_stack with a new chain-link glyph; whitelist it
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
New Flutter app (~/habit_stack) gets an on-style launcher icon from the
shared family: two interlocking stadium-ring outlines, sized so the
72px stroke leaves a genuinely hollow interior at launcher size. Accent
matches the app's own ThemeData seedColor (#66BB6A), not already
claimed by a sibling app.

Also whitelists com.kuhy.habit_stack in phone_focus_mode/config.sh (both
the always-on productive-apps list and the night/curfew health-tool
list) and redeploys, since an unwhitelisted com.kuhy.* app is killed
within ~1s and would otherwise look like an install failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:09:23 +02:00
5d4d3b9e24 night-lockdown: stop firing the morning alarm on a fixed hour
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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>
2026-07-20 08:50:45 +02:00
5faed23977 security-hardening: recognise the guard-lib shutdown guard
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.
2026-07-19 20:09:24 +02:00
2d4331c703 security-hardening: check guard-lib units, not the retired ones
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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.
2026-07-19 19:58:32 +02:00
ee740bf92e Run all 14 shell tests in CI
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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.
2026-07-19 19:39:28 +02:00
896d7b1d15 Run Arch-only shell tests in a container; harden worktree stubbing
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.
2026-07-19 19:27:36 +02:00
e7c3300e80 Repair two stale shell tests; CI now runs all six
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
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.
2026-07-19 19:13:51 +02:00
93a466c8e4 Run shell tests in CI and repair three broken by a reorg
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.
2026-07-19 19:06:23 +02:00
1b7a364e64 requirements-sync: map bs4 and exempt foreign-runtime imports
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Two fixes the checker needs for archwiki_rag, left uncommitted in the
working tree and therefore stashed away by pre-commit on every push:

- bs4 resolves to the beautifulsoup4 distribution; without the mapping
  the checker reports it as undeclared.
- mcp_server is resolved by the knowledge-rag uv tool venv at runtime,
  not by CI's `pip install -r requirements.txt`. Declaring it would be
  wrong, so it is exempted via _FOREIGN_RUNTIME_IMPORTS with a comment
  naming the owning venv.

Not authored in this session; committed because the pre-push CI mirror
cannot pass while these live only in the working tree.
2026-07-19 18:58:35 +02:00
836f4ef2de Add network diagnostics: duty-cycle and line-speed probes
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.
2026-07-19 18:46:18 +02:00
27f44cad4b Add shared launcher-icon generator for the Flutter apps
Every Flutter app still shipped the stock Flutter "beach" launcher icon, and
none had an adaptive-icon descriptor at all. Rather than hand-drawing five
one-off icons, define the family once and let each app contribute only an
accent colour and a glyph.

The family: a charcoal #1B1D21 field carrying one glyph in that app's own
theme seed colour, at a single stroke weight. Glyphs are optically centred
automatically - render.centre_offset rasterises a glyph alone, measures its
ink box, and translates (never scales) the fragment, so a row of icons lines
up while every icon keeps the exact family stroke weight.

safe_box_overflow warns when a glyph exceeds the 560px adaptive-icon safe
box; it caught three real overflows that a launcher mask would have clipped.

The registry points dufs_client at ~/dufs-cloud/app: ~/dufs_client was a
stale, remote-less checkout with an identical package name, since deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:55:48 +02:00
937c597c80 remove(digital-wellbeing): delete unusable focus-mode daemon entirely
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
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>
2026-07-19 13:15:04 +02:00
88eda8dc51 fix(night-lockdown): schedule a dynamic wake-floor trigger for any shutdown time
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
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
2026-07-18 06:51:26 +02:00
e393207e3a fix(night-lockdown): close console-blank gap, recover DP link, floor wake alarm
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
2026-07-18 06:44:17 +02:00
e19f889989 chore: remove byox_ladder — extracted to its own repo
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Has been cancelled
Moved to https://github.com/kuhyx/build-your-x with full git history, where it
gained a `byox` progress tracker (crdt_sync-backed) and the builds themselves.

- git rm -r python_pkg/byox_ladder/
- CLAUDE.md: add build-your-x to the extracted-repos list
- .pre-commit-config.yaml: drop the now-stale byox_ladder codespell exclude

No remaining package imports byox_ladder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nw9Vw8ufxLjkDox7iXPMH3
2026-07-17 20:06:14 +02:00
6342eaf0e2 feat(byox_ladder): add build-your-own-x difficulty ladder generator
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Ranks the codecrafters build-your-own-x projects by difficulty and emits two
offline, self-contained HTML pages: a per-guide ladder (~360 tutorials) and a
per-category ladder (30 categories), grouped into Beginner/Intermediate/Advanced/
Expert tiers plus an Unsorted bucket. A guide inherits its category's tier;
language only nudges order within a tier. Includes a Makefile pipeline
(fetch -> parse -> build) and 100%-branch-covered tests. Excludes the generated
guide page from codespell since it inlines upstream titles verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01QNRTPNYvoBTQoTEWkYtCJD
2026-07-17 18:31:21 +02:00
e0f62d699a fix(wellbeing): split focus_mode_daemon to satisfy the 500-line limit
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
2026-07-17 17:36:30 +02:00
69242e4bb3 fix(brother-printer): split oversized files, fix order-dependent entry test
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
a12e696 turned CI red two ways, both missed locally because the suite was
run for the brother_printer package alone while CI runs everything in a
random order.

The repo caps Python files at 500 lines and cups_service.py had reached
659. Rather than cut it arbitrarily, extract consumable life tracking -
page counters, replacement baselines, the scale migration, dropped-page
detection - into consumables.py. The seam is real: none of it depends on
CUPS service control, and cups_service needs only estimate_consumable_life
for its fallback query, so the dependency runs one way. The test files are
split along the same seams.

test_main_entry passed or failed depending on execution order: runpy warns
when the target module is already in sys.modules, a sibling test imports
it, and pytest is configured with filterwarnings=error. Drop the module
before running it so the test no longer depends on who ran first.

Structural only; no behaviour change. 692 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
2026-07-17 15:06:25 +02:00
a12e696494 fix(brother-printer): report the truth, not invented status codes
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
./run.sh failed outright with ModuleNotFoundError, and once running it
reported a sleeping printer as a critical paper jam. Both are fixed, plus
the defects found while verifying them against the real HL-1110.

Entry point: run.sh ran the script by path, so the absolute
python_pkg.* imports could not resolve. Resolve the repo root and pass
PYTHONPATH as a sudo argument, since sudo's env_reset strips an exported
one. __main__.py had the wrong import prefix and ran main() on import.

Status codes: the table claimed provenance from a "Brother PJL Technical
Reference" that does not appear to exist, and 23 of its 25 entries were
wrong. Rebuilt from the HP PJL Technical Reference Manual, Appendix D,
and verified against six states induced on real hardware (10001 READY,
10003 WARMING UP, 10023 PRINTING, 40000 SLEEP, 40021 TOP COVER OPEN,
41213 NO PAPER) - all six matched the reference, none matched the table.
40000 is SLEEP MODE, which the reference explicitly flags "not an error";
the real jam code 40022 was absent entirely. The inverse also bit: 10006
is TONER LOW, previously shown as a benign "Processing". Codes in no
reference are dropped and now report "Unknown status - check printer
display" rather than a confident wrong answer, and states we infer
ourselves no longer borrow PJL numbers to describe themselves.

Page counts: estimates came from the CUPS page log, which only sees jobs
CUPS logged and counts pages the printer never printed (it logged
total=50 for a job that printed 3). Take them from @PJL INFO PAGECOUNT
instead, migrating stored baselines onto the printer's scale once so the
reported percentages do not jump.

Reading status must not cost anything: a blocking open() hung forever
whenever the printer was unwell - precisely when a status report is
wanted - and the port-status probe stopped CUPS and reset the USB device,
destroying the in-flight job and then misreporting the damage as a
printer error. The fd is now non-blocking with deadline-bounded writes,
and the probe reads the same byte via the free usblp ioctl, skips
entirely while a job prints, and re-attaches the kernel driver.

Dropped pages: the HL-1110 silently discards pages whose 600dpi raster
exceeds its ~1MB memory, stays READY, and CUPS still calls the job
successful - so nothing anywhere reports the failure. Compare CUPS's
claim against the printer's counter between jobs and name the workaround
(lp -o Resolution=300dpi).

402 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
2026-07-17 14:49:23 +02:00
28779586eb fix(wellbeing): symlink focus-mode-daemon so edits actually deploy
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
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
2026-07-17 14:02:33 +02:00
22e22c16a7 fix(wellbeing): stop focus mode reading zombie processes as running Steam
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
2026-07-17 13:55:24 +02:00