Commit Graph

160 Commits

Author SHA1 Message Date
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