Commit Graph

377 Commits

Author SHA1 Message Date
1b19808edb Keep this session's splits shfmt-clean
analyze_repo.sh was shfmt-clean at 133fccf and my split made it dirty
(a double blank line). Fixed with shfmt -w, then restored the STEP 1
comment banner shfmt -w also stripped -- all eight banners re-counted.

The three new shell_check libs inherited their parent's 2-space
indentation; as new files they have no prior style to preserve, so they
are now tabs like shfmt wants.

Context for the exit code: 157 of 298 linux_configuration shell files
already fail shfmt at 133fccf. shfmt is not in .pre-commit-config.yaml
and there is no .editorconfig, so run_linters exiting 1 on the full tree
is the repo's standing state, not a regression here. Scoped this commit
to what these splits touched rather than reformatting 157 files.

Also closes the risk left open in a84556a: run_linters now verified
directly over the three new libs -- all six passes PASS, exit 0 -- with
install_linters never invoked, so decision 6 holds. That is the path
.github/workflows/shell-check.yml runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:05:01 +02:00
936e0ec70d Fix two runtime defects the analyze_repo.sh split introduced
41cc045 passed bash -n, shellcheck, a function-set diff, a line-set diff
and a stubbed source run -- and still shipped a script that aborted after
language detection. Both bugs needed a real execution to see.

1. set -e. The eight `((LANG_FILES[x] > 0)) && HAS_Y=true` lines were
   top-level statements, where a false `&&` merely left $? non-zero. As
   the tail of detect_languages they became its return value, and the
   last one (java, false for most repos) killed the script. Now `|| true`.

2. Self-referencing nameref. `local -n LANG_CODE_FILES="$1"` pointing at
   a global of the same name makes bash 5.3 warn "circular name
   reference" on every access -- into this script's own output. The
   locals are now _code_files.

The rename then hid the array from shellcheck (SC2034), since it was only
named as a bare word. Fixed by passing it through CODE_FILES_MAP so the
reference is real, not by suppressing.

Verified against 133fccf end to end on a fixture repo: same file set,
every text artifact byte-identical, exit 0 both sides, zero warnings.
Only cscope.out.in differs, in binary padding -- same input list, same
90112 bytes.

The lesson for the recipe doc: a stubbed run sources the libs and never
calls them, so it cannot reach a bug in a seam that passes state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:02:17 +02:00
a84556ac2a Split shell_check.sh into an entry script and three libs
428 lines -> 121, with lib/shell_check_lint.sh (171),
lib/shell_check_install.sh (86) and lib/shell_check_discover.sh (78).

The temp-file paths (TMPDIR, ABS_FILES_Z, REL_FILES_Z) are set once in
the entry before any lib is sourced and only read afterwards, so nothing
mutable crosses a seam. shellcheck was clean on the first attempt.

Runtime check is --skip-install --list-only --verbose, diffed against the
same run from a detached worktree at HEAD: 318 files before, 321 now,
the delta being exactly the three new libs the discovery walk now sees.
Nothing removed.

Not verified: the linting path itself. --skip-install does not gate the
AUR branch of install_linters, so a full run cannot be done under
decision 6 -- I learned that by running it, which installed four AUR
build deps before I killed it. The user chose to leave them. run_linters
moved as one intact block with no interior edit and the line-set diff
shows zero lines lost, so the seam risk is confined to its two braces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:57:19 +02:00
d889c8e655 Split music_parallelism.sh into an entry script and three libs
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
412 lines -> 135, with the nine functions in lib/music_detect.sh (133),
lib/music_monitor.sh (91) and lib/music_status.sh (85).

find_music_services and kill_music_services stay together: the first
writes MUSIC_FOUND_PROCESS/MUSIC_FOUND_WINDOW and the second reads them,
so splitting between them would thread mutable state across files.

The test suite caught what the static checks could not. The harness
copies the script into a temp worktree to stub common.sh, and copied
exactly one file -- so after the split every source line failed and the
script died before any behaviour under test ran. Teaching it to copy
lib/ too is a harness fix under decision 8: no assertion changed, the
same five checks run, exit 0 before and after.

bash -n also earned its keep: the first splice dropped notify_user's
closing brace, and shellcheck reported that only as SC1094 "parsing of
sourced file failed" against the entry.

One rename, not a suppression: monitor_loop's local `music_services`
became `active_services` after shellcheck flagged SC2153 against the
MUSIC_SERVICES config array now visible in the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:50:34 +02:00
41cc0451c9 Split analyze_repo.sh into an entry script and five libs
869 lines -> 231, with the extracted helpers and per-STEP blocks in
lib/analyze_repo_{scan,tools,separate,report,symbols}.sh (86-228 each).

The seam that mattered was STEP 4, whose two passes share a map of
language -> temp file. Rather than let that cross a file boundary, the
map is passed by nameref and the comments file by parameter, so
separate_code_and_comments fills what report_languages reads with no
global in between.

Two boundaries were one step off on the first pass and produced 23
shellcheck findings: the KEYWORDS_* tables and detect_languages had been
cut away from their only consumers, so every one read as unused (SC2034)
or unassigned (SC2154). Moving each beside its consumer cleared all 23
without a suppression. The nameref also makes shellcheck read a bare
assoc-array subscript on the left of an assignment as arithmetic;
quoting the nine keys is the fix.

The line-set diff against HEAD earned its place here: it caught a
`find ... -printf` call at original line 719 that had fallen into a
one-line gap between two extraction ranges and been dropped. bash -n and
shellcheck both passed on that broken version.

Verified without running the script (decision 6 -- install_missing_tools
calls sudo pacman, cargo and makepkg): shellcheck clean across all six,
the function set matches HEAD plus exactly the six new wrappers, no
executable line lost, and a stubbed run of the entry preamble defines
all fourteen functions, proving the source lines resolve at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:47:03 +02:00
c05843153d Split setup_dns_blocker.sh, a live systemd unit target
569 -> 211 across three libs. All seven heredocs sit inside functions,
so function boundaries were safe seams.

The script guards its entry with SETUP_DNS_BLOCKER_SKIP_MAIN, which made
a real function-set comparison possible on a script that would otherwise
reconfigure the machine when sourced: an identical 74 names against a
detached worktree at HEAD. The dns-blocklist-refresh unit still resolves
to it and it still parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:31:03 +02:00
af3e4a9b13 Split update_android_hosts.sh; extract its blocklist as data
870 -> 146 across three libs, plus 247 lines of hosts entries moved from
a heredoc into data/android_guardian_blocklist.hosts. A data file draws
no linter, unlike extracting an embedded Python program.

The installed blocklist is byte-identical: md5 7d3f2550 on both sides.

The first attempt cut inside the `case` dispatcher and neither the entry
nor a lib parsed. Redone with the case as a hard boundary - this script
has no main(), so the case block and the argument parsing that feeds it
stay in the entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:23:39 +02:00
ef0a26abc7 Split scripts/lib/mtk_common.sh with the same entry-point pattern
503 -> 132 across three parts. The seven mtk_root consumers are
untouched because the path they source is unchanged.

Identical 28-function set before and after, checked after each of the
two carves. The `<<<` in this file are here-strings, not heredocs -
worth confirming rather than assuming, after enforce_vbox_hosts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:17:50 +02:00
51e3945507 Split scripts/lib/common.sh, the library 49 scripts source
Some checks are pending
billsplit CI / quality (push) Waiting to run
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
620 -> 163, with common.sh staying the single entry point that sources
its own three parts - the shape the handoff prescribes for config.sh, so
all 49 consumers are untouched.

Verified by diffing `declare -F` after sourcing it against the same from
a detached worktree at HEAD: an identical 48-function set. For a library
this widely sourced that is the check that matters, not a passing lint.

The FOCUS_APPS_* arrays moved with is_focus_app_running, the only
function that reads them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 21:14:48 +02:00
a68313f236 Split every over-cap billsplit Dart file
Four lib files and the 536-line UI flow test, into seven new files. Only
Flutter's generated windows/runner/win32_window.cpp remains, and editing
that would be undone by regeneration.

All four lib splits used part/part of first time, applying the rule
learned in focus_owner. models.dart needed a hand fix: it has a
`library;` directive, which must precede any `part`.

60 tests passing before and after. flutter analyze reports the same 4
pre-existing infos as a detached worktree at HEAD - which is how I
caught that an intermediate run's 7 issues were 3 of my own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:57:56 +02:00
2ce86a5176 Split the largest focus_owner Kotlin test file
EnforcementDecisionTest.kt 418 -> 168, with the shared policy builders in
an internal EnforcementFixtures object. Kotlin has no part/part of, so an
object in the same package is the equivalent.

40 tests, 0 failures before and after - read from the JUnit XML, since
gradle reports UP-TO-DATE rather than a count.

Four Kotlin sources stay over the cap: EnforcementRunner's location block
reads the private constructor property `context` and prefs() eleven times
between them, so extension functions cannot reach it. EnforcementLogTest
is one line over and defines helpers between its test methods, which is
not worth restructuring for one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:48:56 +02:00
3c2c2baba0 Split the last over-cap focus_owner Dart test file
status_page_test.dart 309 -> 175, with the release and polling tests in
status_page_actions_test.dart.

An intermediate run showed 97 rather than 94 - the tell that a git
checkout had restored the original while the split copy still held the
same three tests. Counting testWidgets() calls in both halves (5 + 3 = 8)
confirmed the fix.

status_page_state.dart stays at 307: it is one State class whose methods
read seven private fields, so a mixin cannot see them without declaring
the state twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:44:03 +02:00
d9982e53dd Split the focus_owner Dart files under the cap
main.dart 538 -> 130, policy.dart 272 -> 85, enforcement_status_card.dart
268 -> 145, plus both test files.

Every lib split uses part/part of rather than import. The first attempt
used import+export and the analyzer gave 30 errors like "The name
'_Card' isn't a class": Dart privates are visible to the whole LIBRARY,
and a separate file is a separate library unless it is a part.

The test splits separated groups from the testWidgets() calls that had
been initialising the binding implicitly, so four tests failed with
"Binding has not yet been initialized" - both halves now call
ensureInitialized() explicitly.

94 tests passing before and after; flutter analyze reports no issues.
focus_owner/analysis_options.yaml has an uncommitted change from an
earlier session and is deliberately left unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:39:13 +02:00
093e2f6f68 Split the reverse_survivors test files; defer its two core sources
sim.test.ts 698 -> 175 and director.test.ts 315 -> 178, into four new
modules. 160 tests passing before and after.

sim.ts and types.ts are reverted. sim.ts's exported step() calls
survivorStep, which any seam moves out - 63 tests failed with a
ReferenceError while tsc reported only unused imports. types.ts has its
difficulty tables below the type declarations that use them.

Third project now where a TypeScript seam compiles clean and fails at
runtime. In TS, tsc does not tell you a split worked; the test run does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:26:59 +02:00
45b56b3e61 Split the Pi flasher and the boot-repair test harness
raspberry_pi_flash_sd.sh 661 -> 166 and test_boot_repair.sh 315 -> 104.
Heredoc positions were checked before choosing seams this time.

test_boot_repair.sh is the first linux_configuration script in this
effort that could be verified by running it: every case builds a
throwaway root in a temp dir and passes --root. 31 passed after the
split, and 31 passed from a detached worktree at HEAD - a real baseline
comparison rather than trusting the post-split number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:21:44 +02:00
0aa78ea7aa Split three more scripts; make the verifier catch heredoc cuts
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
transcribe.sh 473 -> 39, setup_activitywatch.sh 457 -> 102 and
setup_wireguard_ssh.sh 464 -> 136.

enforce_vbox_hosts.sh is reverted: its seam landed inside a heredoc and
the resulting lib had an unterminated here-document. That is the worst
failure yet, because shellcheck reported it only as SC1094 on the caller
- which reads like a nicety, not a file that will not parse. The
verifier now runs bash -n over every lib before linting the entry and
names the offending file; proven by corrupting a copy and restoring it.

Also guarded a cd before `git clone ... .`, which would otherwise have
cloned an AUR repo into the caller's directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:16:08 +02:00
63650fbc45 Split offline docs and arch optimiser; defer libre_translate
setup_offline_docs.sh 713 -> 121 across six libs, carved one language at
a time since the first cut left offline_cpp at 545. optimize_arch_
desktop.sh 623 -> 118.

Two recurring traps again: a script with no SCRIPT_DIR at all, and libs
that chained rather than being sourced from the entry. Both now checked
for routinely.

libre_translate.sh is reverted and stays at 488. Exporting GENERATED to
fix one SC2034 revealed eighteen more - when a fix uncovers more of the
same, the seam is imaginary, not the fix wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:09:54 +02:00
776e04db89 Split the kcd2_dice_solver test files under the cap
Nine new modules from four over-cap test files. Every kcd2 test file is
now under 250 lines; only four source files remain.

App.test.tsx ended up with no describe blocks and vitest failed with
"No test suite found" - a split that empties a file has to delete it.
The shared `response`/`ordinary` fixtures went to src/test/fixtures.ts,
which vite.config.ts excludes from coverage; beside the source they
would have counted as production code and broken the 100% gate.

288 tests passing before and after, now across 24 files instead of 15,
coverage still 100% on all four metrics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:03:33 +02:00
5a27acd9d0 Split kcd2 simulate and inventoryIo; revert two seams
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
simulate.ts 384 -> 226 and inventoryIo.ts 287 -> 178, with the moved
names re-exported so no importer or test changed.

search.ts and badgeValue.ts are reverted. findBestSet calls enumerateAll
and hillClimb which call back into expand; valueBadge and format are
mutually recursive with the recommendation half. Splitting either makes
the two files import each other.

The split also exposed a TypeScript hazard worth knowing: with the local
Selection type no longer imported, tsc silently resolved it to the DOM
global and complained about anchorNode and focusNode rather than saying
the name was missing.

288 tests passing before and after, coverage still 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:55:52 +02:00
c04001329e Split the kcd2_dice_solver data tables under the cap
dice.ts 415 -> 93 and badges.ts 369 -> 111, with the literal tables in
six new modules. Every previously exported name is still exported from
the same path, so no importer or test changed.

Two import cycles on the way, both of which tsc and eslint called clean
and which only showed up as a runtime TypeError when 11 test files
failed with "Cannot read properties of undefined (reading tin)".
HEADSTART_POINTS now has its own module so both halves of the badge
table can use it at init time.

288 tests passing before and after, coverage still 100% on all four
metrics with the new modules measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:50:54 +02:00
7a74804786 Split three more linux_configuration giants
raspberry_pi_nextcloud.sh 1276 -> 163, root_bl9000.sh 1116 -> 211,
lookup_docs.sh 942 -> 165. Sixteen new libs.

lookup_python was one 300-line function; its keyword-to-anchor mapping
became python_keyword_doc, emitting result and desc tab-separated so the
caller keeps its control flow.

bl9000_mtkclient had cd X; work; cd - with three return paths.
install_mtkclient became a subshell; extract_boot_with_mtkclient could
not, since it exports BOOT_IMG - so the venv check moved before the cd,
the cd is guarded, and the restores use a saved $PWD.

Four more unguarded cd calls fixed, and a third instance of the lib
chaining bug flattened. lookup_docs.sh had no SCRIPT_DIR at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:43:55 +02:00
5bacfa3883 Split setup_nextcloud_raspberry.sh 1383 -> 231
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
Eight libs from the largest script in linux_configuration: five in the
first pass, then three of those carved again since they landed at 272,
292 and 277.

Fixed three more unguarded cd /var/www/nextcloud calls, each right
before an occ command - the installer would have run against whatever
directory the caller was in. Third script with this bug.

check_and_enable_services.sh is reverted and stays at 1337. Its
SERVICE_STATUS array is declared once and written by every check_*
function, so any lib holding them fails SC2034 alone. Fourth file
deferred for shared state, and the clearest: the script is an
accumulator, so there is no seam anywhere in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:37:07 +02:00
346eb1f00b Bring every markdown file under the cap
Twelve prose violations to zero. Eight files split into per-topic
subdirectories with the original left as an index; both SKILL.md files
keep their frontmatter so discovery still works, with detail moved to
references/.

Deleted docs/cleanup-2026-05/ (7 files) and linux_configuration/docs/
VERIFICATION.md on the user's ruling: unreferenced records of finished
work whose rules are now enforced by the polling, shellcheck and shfmt
hooks. PACMAN_WRAPPER_SECURITY.md was split instead - unlike those, it
documents live guarantees.

Every split verified by a sorted diff against HEAD: zero content lines
lost, only the new headings and index links added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:25:59 +02:00
194b006453 Split fix_bluetooth.sh 720 -> 186; defer steam_compatibility.sh
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
Four libs written in one pass rather than four sequential splits, which
is what stops the libs chaining - the entry gets one source block naming
all four and no lib sources a sibling.

steam_compatibility.sh is reverted and stays at 663. Its CACHE_MAP
associative array is declared and filled in what would be the ProtonDB
lib but read by main, so that lib fails SC2034 linted alone. Third file
deferred for the same reason: shared mutable state means the seam is not
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:17:05 +02:00
d9c315e3ef Split setup_searxng.sh 912 -> 184
Six libs: the script has four phases and write_stack alone was 342 lines
because it emits three config files from heredocs. That became three
functions, one per file, each keeping the comment that explains it -
including why settings.yml is mode 640 and not 600.

For a script whose job is writing config, "the functions still exist" is
not enough. Compared the sorted non-comment lines of the original
write_stack against its three successors: identical hash, so every line
of emitted compose, settings.yml and limiter.toml is byte-for-byte the
same and only the wrappers were added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:11:40 +02:00
2527fb37e6 Split three large linux_configuration scripts, defer a fourth
organize_downloads.sh 513 -> 72, setup_kcd2_dice_solver.sh 497 -> 112,
fix_ubuntu_performance.sh 494 -> 150. First 490+ line scripts done; all
three needed two or three seams.

Repeated splitting chained the libs - each sourced the next, because the
tool inserts its source line relative to the file it just wrote.
Flattened so the entry sources all three and no lib sources a sibling.

diagnose_pacman_hook_stall.sh is reverted and stays at 493. Its
transaction runner assigns LAST_ELAPSED that the caller reads and reads
PACMAN_BIN that the caller defines, so linted alone it is SC2034 plus
SC2153. The pre-commit hook lints each lib in isolation and fails, while
shellcheck -x on the entry passes - suppressing that would be papering
over a seam that does not exist.

organize_downloads.sh is the ExecStart of media-organizer.service; the
unit still resolves and the script still parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 19:06:16 +02:00
dbc4d099a3 Commit the shell-split verifier and the traps it found
meta/scripts/verify_shell_split.sh was a scratchpad file; it has caught
five bugs across 18 splits that bash -n and shellcheck both passed on,
so it belongs in the repo. Tested in both directions - it fails on a
deliberately broken source line and passes once restored.

docs/shell-split-recipes.md gains what the last two batches taught:
splitting a file twice can displace the first cut's source line, globals
read across a seam have three different fixes depending on how many, and
unguarded cd shows up constantly because a moved function finally gets
linted on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:59:20 +02:00
7e967de79b Split seven more linux_configuration scripts
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
10-recon.sh, install_exercism.sh, fix_arch_performance.sh,
format_sd_card.sh, setup_dot_resolver.sh, fix_unity.sh and
20-dump-stock.sh, all now under the cap.

Splitting a file twice is the risky move: the second cut on
20-dump-stock.sh dropped the first cut's source line, leaving
verify_dump called but never defined, and the repair then landed the
line inside the lib instead of the entry script. Both fixed - the entry
sources both libs and no lib sources another. Grep the source lines
after any double split.

fix_unity.sh's desktop_dir is read across the seam; renamed to
DESKTOP_DIR to match the file's own convention for globals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:56:05 +02:00
58fdc6c9de Split five more linux_configuration single_use scripts
install_unreal_mcp.sh 395 -> 155, diagnose_arch_performance.sh 377 ->
123 (two cuts), setup_local_llm.sh 370 -> 163, repo_to_study.sh 365 ->
160, setup_dwm.sh 359 -> 147.

The SCRIPT_DIR fix from the last batch held on all five. Fixed a second
unguarded cd, this time before the study-material generator ran - the
same latent bug class as build_website, surfaced because a moved
function gets linted in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:51:28 +02:00
1a75ed5bf5 Split three more linux_configuration feature and fix scripts
setup_syncyomi.sh 408 -> 138 (two cuts), setup_personal_website.sh
396 -> 192, fix_thorium.sh 377 -> 208.

All three already declared SCRIPT_DIR and the splitter added a second
one, which aborts at runtime with "readonly variable" while bash -n and
shellcheck stay clean. The stubbed run caught it; the splitter now
reuses an existing declaration.

Fixed SC2164 in build_website rather than suppressing it: a bare
cd "$WEBSITE_SRC" inside a subshell, with pnpm install and pnpm run
build after it. A failed cd would have built in the caller's directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:48:01 +02:00
9e62430b8d Split two more linux_configuration scripts; revert a third
control_from_mobile.sh 415 -> 180 and setup_periodic_system.sh 408 -> 228.

setup_periodic_system.sh has no main(): it defines functions then runs a
block of top-level calls, and the splitter moved that block into the lib,
changing when it ran. Redone by hand moving only definitions. It also
asks for sudo at source time, so the stubbed run is off-limits - verified
instead that the source line precedes the first call and all eleven
invoked functions are defined exactly once across the two files.

clean_audio.sh is reverted and stays over the cap. Two probe-result
globals are read from what would be two different libs, and renaming
them to satisfy SC2154 only moved the warning along. That is a refactor,
not a line-count split, and forcing it would have cost a suppression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:44:18 +02:00
658988ef9b Split three near-miss linux_configuration scripts
turn_off_auto_idle_screen_shutdown.sh 289 -> 180, mtk_root/install.sh
262 -> 141, setup_gitea.sh 255 -> 198. A deliberately small batch to
check jscpd before scaling; it stayed well under 2%.

The stubbed run earned its place again: mtk_root/install.sh aborted with
"SCRIPT_DIR: readonly variable" because it already declared one, and
both bash -n and shellcheck were clean on that broken file.

Two shellcheck findings the split exposed are fixed at the source, not
suppressed: persist_with_systemd_logind now takes the flag as an
argument instead of reading the caller's global, and a dead python_bin
assignment is gone. Neither was introduced here - the pre-split file
already had three SC2034 warnings.

No script was executed; all three are installers or enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:38:43 +02:00
dadd33f82d Split install_joplin.sh and establish the shell-split pattern
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
299 -> 103, with the --with-server half in lib/joplin_server.sh. First
of the 84 linux_configuration files, done slowly to settle the pattern.

Three things bash -n and shellcheck do not catch, all found here:

- SCRIPT_DIR from "$0" breaks when the entry script is itself sourced.
  Use "${BASH_SOURCE[0]}"; keep readlink -f for the one symlinked script.
- A lib needs a shebang AND +x: without the shebang shellcheck raises
  SC2148, with it the shebang-executable hook fails. Match common.sh.
- Only a stubbed run proves the source line resolves. Copy entry+lib to
  a mktemp -d, sed `main` into a declare -F probe, run that. No
  installer step executes.

Also enumerates which scripts escape the repo: three copied out by
install_pacman_hooks.sh, one symlink, everything else generated. All
recorded in docs/shell-split-recipes.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:33:59 +02:00
4b2cd0c398 Clear the wsg_grabber test modules: python_pkg is now under the cap
The last six over-cap files split into eleven new modules, in two passes
since the first cut left five still over. python_pkg has no violations
left at all, down from 50 when this effort started.

Each split carries the whole preamble including the module's pytest
fixtures - started, toolkit and session are taken as parameters and
resolve lexically, so a hand-written import block would have missed
them. ruff then pruned 82 unused imports.

Pass count is 277 before and after, checked after both passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:19:26 +02:00
b60cb4972a Clear five python_pkg packages under the cap
android_ui, app_icons, token_audit, focus_policy and random_jpg are now
entirely under 250 lines. Only wsg_grabber's six test modules remain in
python_pkg.

android_ui/driver.py 411 -> 205 took two cuts and a class split: the
element and error types plus the XML parser to _elements.py, and the
text-entry methods to a TextEntryMixin, using the ABC-with-abstract-
methods shape because the tests call them as bound attributes.
app_icons/glyphs.py 291 -> 93 by moving the twelve path fragments out.

Three intermediate failures, all module-level globals that travelled
with their code (_logger, re, _SAME_FIELD_X_TOLERANCE_PX) - the same
class of miss as _make_verifier, which ruff cannot flag.

Counts unchanged: 81, 137, 14, 44, 57.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:14:36 +02:00
e3214c813c Clear the seven over-cap code_tutor test modules
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
Partitioned at the section banners each file already used, into ten new
modules named for what they cover. python_pkg/code_tutor is now entirely
under the cap, from thirteen violations.

The splitter copies the whole original header into each new file and
lets ruff's F401 pass prune it - 59 imports removed correctly, versus a
hand-written import block failing on every earlier test split today.
That does not cover module-level helpers: _make_verifier sat between two
banners and was left behind by its callers, so it moved to conftest.py,
taking test_verifier.py's shadowing copy of _make_live_mock with it.

No patch string changed. Pass count is 217 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:06:38 +02:00
eb322dcf80 Split the Claude backend out of code_tutor/_llm
_llm.py 295 -> 217, clearing the last code_tutor source violation; only
its seven test modules remain over the cap.

The seam falls between the two backends because each pins a different
plain import: test_llm.py patches requests.post on _llm for
OllamaBackend and anthropic.Anthropic for ClaudeBackend. _llm keeps its
own anthropic import - complete_with_fallback still catches APIError -
so the four anthropic patch strings were split per test rather than
moved wholesale.

Pass count is 217 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:00:53 +02:00
1d1938ce23 Split code_tutor cli and verifier, deleting two lint suppressions
cli.py 345 -> 222 and _verifier.py 347 -> 228, via _cli_checks.py and
_verifier_support.py.

The cli seam cut straight through code covered by its per-file-ignores.
Rather than copy S607 and PERF203 onto a second entry, both are fixed:
systemctl is resolved with shutil.which, and the readiness poll no
longer carries a try/except in its body now that the probe is its own
function. Both ignores are deleted from meta/pyproject.toml and ruff
passes without them - the repo has two fewer suppressions than before.

The which() fix adds a systemctl-is-None branch, so the pass count goes
216 -> 217: one new test for it, no existing test touched. Coverage is
back at 100% by covering the branch, not excluding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:57:38 +02:00
c113a3a192 Split the code_tutor import graph and scan tables out of _analyzer
_analyzer.py 388 -> 226. The file walk, dotted-name map, import
resolution and fingerprint go to _deps.py; the skip frozensets and the
function regex to _scan_tables.py, which covered the last nine lines
after the first cut landed at 259.

_is_binary is the only name any test patches on _analyzer and it stays
put, so no patch string needed editing. Only test_analyzer.py's import
block had to be split.

Pass count is 216 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:48:32 +02:00
277cb9fd6f Split the code_tutor challenge flows out of _challenge
_challenge.py 467 -> 236. The two flows and run_coding_challenge go to
_challenge_flows.py; the three static prompt strings to
_challenge_prompts.py, which covered the last eleven lines after the
first cut landed at 261.

The 13 patch strings in test_challenge_part3.py were repointed per test,
by which function is under test - the _run_user_impl tests keep
_challenge. Getting that wrong does not fail here, it HANGS: an
unintercepted _collect_and_rate_tests ran the real interactive flow and
spun the suite for two minutes. Runs are now under an explicit timeout,
and the patch targets are asserted to resolve before pytest starts.

Pass count is 216 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:42:50 +02:00
40a4eaf5b5 Split the code_tutor pytest runner and verdict streaming out
_challenge_support.py 402 -> 237. _pytest_clean and _patch_and_test go
to _pytest_runner.py with the subprocess/os/sys imports they solely used;
_stream_verdict and _parse_verdict go to _verdict.py with rich's Live.

code_tutor patches by absolute string rather than a MOD constant, so the
seam cost 15 per-name rewrites instead of one sed. Two intermediate
failures caught by running it: the test modules import the moved names
directly, and five patch strings still named the old module.

Pass count is 216 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:33:58 +02:00
2c01a0267e Clear the last three brother_printer test modules over the cap
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
test_consumables_part2.py 273 -> 133 (renamed test_counter_scales.py),
test_consumables.py 272 -> 123, test_check_brother_printer.py 275 -> 112.
python_pkg/brother_printer is now entirely under the 250-line cap, from
fifteen violations at the start.

The consumables split lands exactly on the MOD/MOD_PD boundary an earlier
commit introduced, so the two-constant workaround dissolves instead of
being carried forward - each file has one module under test again.

Pass count is 402, unchanged across all eight commits of this pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:27:07 +02:00
74eee2b57f Split the brother_printer display and usb_query test modules
test_display.py 363 -> 204, with the supply maths and its display going
to test_supply_levels.py. test_usb_query.py 328 -> 126, with the retry
and sequencing tests going to test_usb_query_sequence.py.

That removed the last reader of the six _supply names through display,
so display.py's __all__ shrinks to the two public entry points and the
stale comment about tests reaching them there is gone. Checked by grep
across python_pkg and an import-time attribute probe, not by the suite.

The first run after the usb_query split reported 393: TestParsePagecount
sat between the two moved classes and travelled with them. Fixed.

Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:23:47 +02:00
607ba98c85 Split the brother_printer cups_service test modules under the cap
test_cups_service.py 390 -> 172 (usblp port status to _port),
part2 261 -> 115 (status mapping and discovery out), part3 260 -> 105
(the four healthy-port cases out). part3 was one 235-line class, so it
split by method rather than by class.

The four part-numbered files are renamed for what they cover; three of
them now name _cups_fallback, matching where the code moved earlier.

The first run after the part2 split reported 400, not 402: the moved
classes needed subprocess and get_status_info that my hand-written
import block had dropped. Fixed, and the count is 402 again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:19:55 +02:00
65bc65c7e3 Split the brother_printer cups_queue test modules under the cap
test_cups_queue.py 458 -> 167, partitioned at class boundaries into
_actions (the four shell-out primitives) and _backend (health check and
error-log scanning). test_cups_queue_part2.py 278 -> 131, with the four
_handle_* dispatchers moving to _handlers.

No MOD changed: unlike the source splits, every patched name already
lives in the module each file names, so this is a pure partition. The
two part-numbered survivors are renamed for what they cover.

Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:15:55 +02:00
ad3d70f197 Split brother_printer PJL wire transport out of usb_query
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
usb_query.py 267 -> 180. The non-blocking fd handling - _drain_buffer,
_write_with_deadline, _read_nonblocking, _wait_for_pjl_response and
pjl_query - moves to _pjl_io.py.

usb_query imports the three names test_usb_query.py patches back, which
is safe here only because all three still have real call sites in the
code that stayed; an import-only name would be stripped by ruff's F401
sweep and break the patch. os and time also stay: they are patched on
usb_query as well as on _pjl_io.

test_usb_query.py needs no edit. Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:11:15 +02:00
a438652aaa Split brother_printer dropped-page detection out of consumables
consumables.py 279 -> 217. check_page_delivery and _snapshot_counters
move to _page_delivery.py.

The state-file primitives stay put: test_consumables.py patches
_load_consumable_state, _save_consumable_state and CONSUMABLE_STATE_FILE
on consumables, and moving them would have let a test write to the real
~/.config/brother_printer/state.json. Verified by mtime after the run,
not just by a green suite.

test_consumables_part2.py patches the same three primitives from both
sides of the seam, so it gains a second MOD_PD applied per class rather
than a blanket swap. Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:07:21 +02:00
143dd9ff3d Split brother_printer CUPS fallback query out of cups_service
cups_service.py 406 -> 234. The IPP attribute queries, the CUPS
status-code mapping, find_cups_printer_name and query_usb_via_cups move
to _cups_fallback.py; cups_service keeps service control and raw USB
port status.

The seam is not the section header: test_cups_service_part2.py imports
_get_cups_economode and find_cups_printer_name together and patches
shutil once, so those two had to travel as a pair. test_cups_service.py,
the largest module, needs no edit.

usb_query.py reaches the fallback through a late import_module, so both
that string and the absolute patch target in test_usb_query.py moved
with it. Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:02:42 +02:00
cf44e54215 Split brother_printer queue display and fix prompt out of cups_queue
cups_queue.py 424 -> 245. display_cups_queue_status, _offer_queue_fix,
the _dwj_* choices and the _handle_* dispatchers move to _queue_fix.py.

The _cups_* action primitives stay put: test_cups_queue.py patches
subprocess and time on cups_queue, and those primitives are their only
consumers, so moving them would have left subprocess.run unpatched and
free to restart the real CUPS service mid-suite. test_cups_queue.py
therefore needs no edit; only part2/part3 repoint their MOD.

Pass count is 402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:53:51 +02:00
386ff25296 Split brother_printer page-count display into its own module
display.py 342 -> 248, under the 250-line cap. The page-count estimate,
the dropped-page warning and the consumables reference move verbatim to
_page_count.py, which imports the two collaborators the tests patch
(check_page_delivery, estimate_consumable_life) directly.

test_display_part3.py patched only those two names, so it becomes
test_page_count.py with its MOD pointing at the new module. Pass count is
402 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:50:02 +02:00
2832b7841f Reduce brother_printer/display.py and dedup the consumable bars
Partial: display.py 427 -> 342. Still a violation, not yet cleared.

Moved out only what no test patches:
  _supply.py    the supply-level arithmetic (imported by tests, not patched)
  _severity.py  the three _SEVERITY_* tables (pure data)

Also collapsed the toner and drum bars in _display_page_count_estimate into
_supply.render_life_bar. They were the same logic with different colours and
notes, so this removes duplication rather than relocating it.

The remaining ~92 lines are blocked on mock.patch pinning. This package's test
modules patch ~90 names via patch(f"{MOD}.<name>"), and a patched name only
resolves in the module the test names -- moving a function that calls one
breaks the mock. An attempt to move the page-count block was reverted for
exactly that (display has no attribute 'estimate_consumable_life'). Clearing
it needs either test edits or ~8 delegate stubs, which is a decision to take
rather than assume.

402 tests pass, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:39:02 +02:00
e03fbd17e9 Split wsg_grabber/tests/test_review.py under the 250-line cap
Split along the same seams the source took: test_review_render.py covers what
now lives in review._render, test_review_undo.py covers review._undo.

The two fixture builders they share moved into the existing conftest.py rather
than a _helpers.py -- the name-tests-test hook requires everything else under
tests/ to be named test_*.py, and syncyomi_guard/tests/conftest.py is the
precedent for plain shared builders living there. Sharing beats copying: jscpd
fails the commit above 2% duplication and this repo already carries four partN
test files.

277 passed, the same count as before, so nothing was dropped. Two intermediate
runs caught real NameErrors from imports the extracted module needed.

test_review.py 297 -> 112, test_review_render.py 102, test_review_undo.py 79.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:24:48 +02:00
d86965ff3a Split wsg_grabber/ui.py under the 250-line cap
DisplayMixin in _display.py takes the methods that reach widgets only through
self._widgets: apply, _play, _verdict, on_undo, on_quit, drain, refresh, tick
and attach_player.

Widget construction stays in ui.py on purpose. test_ui.py fakes the toolkit
with patch.object(ui, "tk", fake), so moving _build out makes 22 tests error
with "module ui does not have the attribute 'tk'". An earlier attempt did that
and was reverted rather than touching the tests.

Also dropped the four _key_* adapters; each only discarded Tk's event argument
before forwarding, so _bind_keys binds lambdas directly now.

One thing worth knowing for the next split: ruff's TC001 is NOT suppressed by
__all__ the way F401 is. Moving Callbacks to its own module and re-exporting it
left a TYPE_CHECKING-only import that lints clean and raises AttributeError at
runtime -- cli.py constructs ui.Callbacks. Reverted; it stays defined in ui.py.

ui.py 398 -> 242, _display.py 179. 277 tests pass, both files 100% covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:18:13 +02:00
16c626661d Split wsg_grabber/player.py under the 250-line cap
The stateless half of talking to mpv moves out:

  _mpv_ipc.py    read_reply, build_argv, encode, note_errors, and
                 probe_properties/query lifted out of MpvPlayer.probe/_query,
                 which only ever needed the socket path
  _player_api.py the Player Protocol that ui.py actually depends on

player.py keeps the class that owns the process and the socket, and re-exports
Player/build_argv/encode through __all__.

_note_errors stays as a bound delegate on MpvPlayer: test_player.py calls it on
the instance, and the tests are out of scope here. Caught by the suite failing,
not by guessing.

The probe constants moved across verbatim -- _PROBE_ID is 9001, and read_reply
matches replies on it, so a placeholder would have made every probe hang until
timeout while still returning a plausible error string.

player.py 373 -> 235, _mpv_ipc.py 158, _player_api.py 49. 277 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:05:41 +02:00
324ec0c9b5 Split wsg_grabber/downloader.py under the 250-line cap
Worker did two separable jobs. Both become mixins, because the tests drive
their methods as bound attributes (worker._download_one(), worker._fetch_thread)
and free functions would have broken that:

  _scan.py    ScanMixin      -- threads.json, the archive, one changed thread
  _fetch.py   DownloadMixin  -- claim a file, fetch it, record the outcome

_worker_base.py holds WorkerBase(ABC) with the state both halves touch and the
three methods Worker supplies. Two earlier shapes were abandoned rather than
suppressed: NotImplementedError stubs are uncovered lines under the 100% gate,
and `if TYPE_CHECKING` stubs satisfy mypy but leave pylint reporting no-member.
The ABC satisfies both with no disable comment anywhere.

_downloaded needs a real class-level default: _fetch does `self._downloaded += 1`
and pylint only accepts an attribute as assignable when there is something to
assign to. Worker.__init__ still owns the instance value.

downloader.py 420 -> 238, _scan.py 134, _fetch.py 98, _worker_base.py 52.
277 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:01:11 +02:00
f58caea2ab Split wsg_grabber/store.py under the 250-line cap
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
store.py mixed the writes that move a file through its lifecycle with the
read-only projections over the same table. The reads move to _store_queries.py:
claimable_values, ready_items, ready_item, counts, pending_downloads,
known_md5s, state_of, attempts_for.

claim_next goes with them despite being a write. It is the one write that has
to be a single atomic transaction against exactly those reads, so splitting it
from them would have left BEGIN IMMEDIATE on one side of a module boundary and
its SELECT on the other.

_scalar becomes scalar: record_files still counts rows before and after its
insert, so store imports it back. The dependency runs one way only.

store.py re-exports through __all__, so downloader.py, cli.py and test_store.py
keep reaching everything at store.* unchanged -- all 15 names verified to
resolve after ruff's autofix pass.

store.py 382 -> 202, _store_queries.py 232.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:52:08 +02:00
262ef38efe Split wsg_grabber/net.py under the 250-line cap
net.py spoke HTTP for two very different things. The media transfer moves out:

  _download.py  Transfer, download, _stream, _over_budget, _write_body
  _reject.py    classify() -- status line and headers to an Outcome

net.py keeps build_session and get_json and re-exports the transfer API
through __all__, so downloader.py and test_net.py keep using net.download /
net.Transfer / net.digest_of / net.size_of untouched.

digest_of had to move down into _download rather than stay put: _stream needs
it, and importing it back up from net was a real runtime cycle. net re-exports
it. _rejection became classify because it is now imported across a module
boundary.

Note for the next split: ruff's F401 autofix stripped 11 dead imports here in
one pass. __all__ is what stops it doing the same to a re-export.

net.py 335 -> 109, _download.py 205, _reject.py 96.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:45:39 +02:00
62ad1484a6 Split wsg_grabber/review.py under the 250-line cap
review.py held three separable concerns. Two move out:

  _render.py  emptiness, status_line, filename_line, title_line, render --
              pure state-to-text, no toolkit and no transitions
  _undo.py    can_undo, on_undo, forget_last -- correcting the tally and
              putting the on-screen video back at the front of the queue

review.py keeps the state transitions and re-exports all eight names through
__all__, so ui.py and test_review.py reach them at review.* exactly as before;
verified by resolving every name in __all__.

Both new modules reference SessionState under TYPE_CHECKING only, so there is
no runtime cycle. Also inlined _worker_message, a single-caller passthrough
that just returned event.message.

review.py 330 -> 196, _render.py 107, _undo.py 74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:42:10 +02:00
8ce453690f Split wsg_grabber/cli.py under the 250-line cap
The `logs` subcommand's reporting -- show_logs() and summarise() -- is
read-only analysis of the JSONL session logs and shares nothing with the
scrape/review flow, so it moves to _logs_report.py.

cli.py re-exports both through __all__ so cli.show_logs / cli.summarise stay
valid for test_cli.py and the dispatch table. The PEP 484 `X as X` alias form
does NOT survive here: ruff flags it PLC0414, autofixes it away, and then
deletes the import as unused under F401 -- which would break cli.summarise
with lint still green. Recorded in the handoff so it is not re-derived.

cli.py 282 -> 247, new _logs_report.py 76. 277 wsg_grabber tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:39:10 +02:00
77c6d9f815 Split wsg_grabber/catalog.py under the 250-line cap
catalog.py was 251 lines. Its five defensive accessors for the loosely-typed
/wsg/ JSON -- _get, _as_list, _as_int, _as_md5 and the _MD5_SHAPE / int64
range constants they depend on -- form a cohesive unit distinct from the
parsing logic above them, so they move to a sibling _coerce.py and become
module-public there.

No caller or test referenced the private helpers (downloader.py uses only the
six public parse functions), so no re-export shim was needed and the public
API is byte-for-byte unchanged.

catalog.py 251 -> 175, new _coerce.py 87. Both modules stay at 100% branch
coverage; 277 wsg_grabber tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:36:02 +02:00
73b85f5326 Split poker-stakes' four oversized files under the 250-line cap
useGame.test.tsx (494) splits by describe-block into reducer/session/debt
files; handEval.test.ts (269) splits at the evaluate7 boundary; useGame.ts
(268) sheds its state and reducer into gameState.ts; handFlow.ts (252) sheds
its betting-state helpers into handFlowBetting.ts.

useGame.ts and handFlow.ts re-export what they moved, so no importer changed.

The shared test helpers live in src/test/ rather than beside the source they
exercise. That is load-bearing: vite.config.ts already excludes src/test/ from
coverage, so no new exclusion entry was needed. Putting them next to the source
first dropped coverage to 98.57% because v8 counted the fixtures' unused
branches as production code -- fixed by moving them, not by excluding them.

Verified: 220 tests still pass, coverage back to 100% on statements, branches,
functions and lines, and npm run build succeeds. poker-stakes violations 4 -> 0;
repo total 196 -> 192.

invariants.test.ts hit its 5s timeout once mid-work. It passes standalone in
2.56s and on re-run, so it is a pre-existing contention flake under parallel
coverage load rather than a regression here -- noted, not papered over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:26:18 +02:00
72a5e28456 Track poker-stakes so the file-length cap can reach it
The directory has existed on disk with 58 untracked files, four of them over
the 250-line cap. An untracked file is invisible to every pre-commit hook, so
those violations could not be fixed or gated while it stayed that way -- and
its CI workflow was untracked too, so nothing ran against it.

Landed with its already-written evidence artifact and a contract, since this
crosses the 4-file threshold.

Verified before staging: 58 plain text source/config files, no binaries, no
node_modules, no build output. npm run check passes -- typecheck, eslint
--max-warnings 0, and 220 tests at 100% statements/branches/functions/lines.

Splitting its four oversized files is deliberately a separate commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:19:07 +02:00
004b4b0b29 Cut CLAUDE.md 259 -> 141 lines: delete what hooks already enforce, fix what was wrong
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 file loads into context on every turn of every session, so every line is
paid for repeatedly. Two thirds of it either duplicated a gate that already
fails the commit, or stated something untrue.

Deleted because a hook enforces it: the ruff/mypy/pylint/coverage settings,
"double-quote expansions" (shellcheck runs with no severity filter), absolute
imports, Google docstrings, type hints, and the inline-Python rule -- which
got a real gate in 17b20e5 rather than being dropped.

Corrected four claims that were simply false, all verified against the config:

- pytest-coverage was listed as a pre-push hook; it is stages:[pre-commit].
  Pre-push is prettier + ci-mirror.
- the root-symlink list named requirements.txt twice and omitted lint_python.sh
- "scripts/" described a directory that does not exist at repo root
- per-file-ignores were listed as S101/PLR2004/S310/S607/PLC0415; the actual
  set is ARG/D/PLC0415/PLR2004/S101/SLF001

Added the five top-level dirs the layout table omitted (poker-stakes, bottles,
bucket_catch, focus_owner, kcd2_dice_solver) and documented three gates that
were enforced but undocumented: the 4-file contract threshold, append-only
session logs, and the 2% jscpd limit.

Also recorded two places the gate is weaker than it looks: the mypy hook
disables ~20 error codes despite strict=true, and no-polling-antipatterns only
inspects *_loop/*_daemon/poll* functions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:17:02 +02:00
17b20e5fae Gate the no-inline-Python rule instead of asking people to remember it
CLAUDE.md's longest and most emphatic code rule -- never embed Python program
logic in a shell script -- was enforced by nothing. A repo-wide scan found it
had already been violated: install_plagiarism_tools.sh ran a 6-line NLTK
download as an inline `python3 -c`, invisible to ruff, mypy, pylint, bandit
and pytest, exactly the failure mode the rule exists to prevent.

check_no_inline_python.sh flags heredocs and multi-line `python -c` while
permitting the single-line availability probes the rule explicitly allows, so
the ~250 shell files here produce zero false positives. Commented-out examples
are skipped; otherwise the rule's own documentation would fail the hook.

The extracted download_nltk_data.py also fixes a real defect: the inline block
ignored nltk.download()'s return value, so a partial corpus download reported
success. It now returns non-zero and the caller's `set -e` sees it.

nltk is imported via importlib inside main(), not at module scope: it belongs
to the plagiarism toolchain, not this repo, so it is absent from
requirements.txt and unresolvable to pylint as a static import.

No suppressions anywhere: T201 avoided with sys.stderr.write, INP001 with a
shebang (matching sibling fast_count.py), E0401 with the dynamic import.
pylint 10.00/10, ruff clean, mypy clean.

Verified the gate fires rather than passing vacuously -- a staged canary with
a real heredoc exits non-zero -- and that the extracted script runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:11:56 +02:00
95a1f62fc4 artgate: add 64px icon spec for the diffusion route
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 diffusion route generates at SDXL's native 1024px, so a 32px icon is a
32x downscale. Detail that survives the generator does not survive that
reduction -- a coin's struck emblem, a shield's radial pattern and a
lantern's glass panes all mush at 32px and read clearly at 64px.

max_colors is deliberately NOT scaled with the canvas. Measured over the 300
genuine 48x48 icons in ~/opengameart-downloads: p50=9 p90=13 max=14. A cap of
32 rejects 0.00% of that art and so does every larger cap, so hand-made art
does not spend more colours on a bigger canvas. Raising it would only admit
more un-quantized generator output.

Gates 18/18: the 12 original bake-off subjects re-finished at 64px, plus six
subjects the route had never seen (lantern, axe, boots, helmet, torch,
mushroom).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 23:30:32 +02:00
a0db432933 artgate: render 3D route with Standard view transform, not AgX
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
Blender defaults to the AgX view transform, a photoreal film emulation
that rolls off highlights and desaturates. On the flat-lit item renders
the 3D route produces, it turned gold into beige and washed out every
material -- which is why row C read as unusable in the bake-off.

Measured on an identical coin scene where only view_transform differed:
mean saturation 0.282 (AgX) vs 0.787 (Standard), a 2.8x increase.

This makes row C a fair test, not a good one: the geometry is still
low-poly primitives and ring/bone remain nearly invisible at 32px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 23:09:06 +02:00
e6e0ba5ab3 artgate: diffusion + 3D routes, full vector coverage, and the margin gate
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
Closes the remaining bake-off cells. The phase's real result was not a route
score but a MISSING GATE.

All twelve SDXL icons measured 100% opaque coverage -- square tiles with no
sprite silhouette -- and passed every gate in the harness. Gate-pass was
measuring pipeline hygiene, not usability. MARGIN_TOO_SMALL inverts the table
from a meaningless 12/12/12 tie to:

    diffusion   0/12      (every output full-bleed)
    vector     10/12
    procedural 11/12

The two non-diffusion failures were true positives in MY shapes, not gate
error: the procedural sword pommel ran off the bottom edge and the vector meat
bone clipped the top. Both fixed; both routes back to 12/12.

Diffusion recovers to 12/12 with flood-fill background removal, but visual
inspection shows the fill eats into the art (bone reduced to a fragment, sword
nearly erased). Reported as inadequate rather than shipped -- proper matting is
needed before route (b) is a usable pipeline.

Blender exits 0 even when its embedded script raises: BLENDER_EEVEE_NEXT does
not exist in 5.2, and the failure produced a clean exit and no file. render()
now asserts the output exists, or the route would report phantom successes.

Model gating verified at execution time, never recalled: SDXL base and
nerijs/pixel-art-xl are ungated, FLUX.1-dev is gated=auto, and
runwayml/stable-diffusion-v1-5 now 307-redirects.

The finishing stage now generalises across three continuous-tone sources
(SVG raster, Blender render, SDXL): each is 0/N raw and N/N finished.

Verified: 114 tests, 100% branch coverage on 13 modules, zero suppressions,
all hooks pass. Note: a local pytest run masked a missing branch that the
pre-commit hook caught -- the hook's run is authoritative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:51:31 +02:00
691a228499 artgate: procedural + vector routes, and the shared finishing stage
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
Closes the two CPU-only cells of the art bake-off and builds the bridge the
diffusion cell will reuse.

Pins the bake-off to the SAME twelve subjects item-icons measured, with its
easy/hard split intact. Without this a route tested on distinctive silhouettes
beats the 6/12 baseline for free and the decision table means nothing. It also
makes the subject-routing rule falsifiable: a generator route is predicted to
lift specifically the six human-prior failures, and if it does not, the rule is
recorded as wrong.

Route (a) procedural: all twelve subjects drawn deterministically from a
7-colour locked palette. Every shape gets a distinguishing feature up front,
because item-icons measured that plain primitives read as placeholders however
correct the geometry is. Machine gate: 12/12 -- which proves "not malformed",
NOT quality. A procedural generator satisfies palette/alpha/canvas constraints
trivially; the human contact sheet is the real verdict.

Route (d) code-as-art: SVG emitted as text and rasterised by rsvg-convert, so
no new Python dependency (cairosvg/svgwrite stay absent).

The structural finding, measured on real rasteriser output rather than a
synthetic stand-in: raw vector renders pass 0/5 -- every one fails
ALPHA_NOT_BINARY, most also TOO_MANY_COLORS. After the finishing stage, 5/5.
Continuous-tone routes are therefore GENERATORS FEEDING A FINISHER, not
pixel-art generators. finish.py binarises alpha before quantising colour,
mirroring the gate order: quantising first blends transparent pixels' arbitrary
RGB into the palette.

Verified: 92 tests, 100% branch coverage across all ten modules, zero
suppressions, all hooks pass. The PLR2004 lint refactor was confirmed
pixel-neutral by byte-comparing all 12 icons before and after (12/12 identical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:31:59 +02:00
da8ca6d827 artgate: deterministic exit-code gates for generated game art
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
Adds the route-independent spine of the planned art-generation bake-off: a
harness that decides whether generated art is well-formed, so "non-sloppy"
becomes machine-checkable instead of a judgement call.

Findings that shaped the design, all measured rather than assumed:

- Integer-scale invariance separates true pixel art (impurity 0.0000) from
  interpolated fakes (1.0000) exactly -- but passes trivially at scale 1, so
  it must never be trusted alone on native-resolution output.
- Gate ORDER is load-bearing. A soft-alpha image has no fully-opaque pixels,
  so an opaque-only colour count returns 0 and silently passes. ALPHA_NOT_BINARY
  now runs first and COLOR_COUNT fails closed on an empty opaque set.
- Counting must be over opaque RGB only: transparent pixels retain arbitrary
  RGB and inflated a naive RGBA count, rejecting correct art at 18/16.
- Declaring a gate was not enough to run it. hand_painted.toml shipped
  declaring SILHOUETTE_LOW_CONTRAST with no implementation, so it parsed, ran
  nothing, and reported a pass. load_spec now fails closed on unimplemented
  gates, result-only codes, and gates missing their config.

Thresholds are calibrated against 250 genuine 32x32 CC0 icons, not guessed:
p50=7 p90=13 p99=26 opaque colours; cap 32 rejects 0.0% of real art while raw
generator output measured 34403 colours. An initial cap of 16 wrongly rejected
a real DCSS icon -- re-derived from the corpus rather than nudged.

Verified: 57 tests, 100% branch coverage, zero suppressions, all hooks pass.
58/60 (96.7%) of real CC0 icons pass the full stack; both failures confirmed
true positives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:20:36 +02:00
54054e7cf9 billsplit: use the shared SectionHeader instead of the local copy
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
billsplit CI / quality (push) Has been cancelled
`design_system`'s `SectionHeader` was built as the union of untools'
`_SectionHeading` and billsplit's `_SectionHeader` -- its `defaultPadding`
is `fromLTRB(md, md, md, xs)` specifically to reproduce billsplit's
`(16, 16, 16, 4)`. The shared widget shipped and untools' donor was
deleted, but billsplit's never was: the file has imported
`package:design_system/design_system.dart` since the migration while still
defining and using its own copy. That is a duplicate shipped rather than
removed, which is the failure the extraction phases exist to prevent.

Verified equivalent rather than assumed: padding `(16,16,16,4)`, glyph
origin `(16,16)`, line height 24.0 and type size 16.0 are all identical
to the retired widget. The one difference is that the shared version wraps
the title in `Row`/`Expanded` to leave room for its optional trailing
action, so the `Text` box fills the row instead of shrinking to content --
invisible for a left-aligned title with no action, but it does change
hit-test width, so it is worth stating rather than calling a pure no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:26:55 +02:00
40d2c96a77 billsplit: adopt the shared palette, retire the teal seed
Some checks are pending
billsplit CI / quality (push) Waiting to run
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
Last of the four drifted apps. The theme was ColorScheme.fromSeed(teal)
plus fifteen ad hoc colour literals that mostly bypassed it -- not one
widget outside main.dart read colorScheme at all, so the seed barely
affected what you saw. It now uses design_system-v0.1.0's
buildLightTheme(), and the literals that have a real theme role read it
off the scheme.

Light, not dark, deliberately: the receipt screens carry black-on-white
helper text, so inverting brightness would make it unreadable. The
Colors.black54 sites are exactly those, now onSurfaceVariant.

The error family maps cleanly -- Colors.red to error, red.shade50 to
errorContainer with onErrorContainer text on it, and the swipe-to-delete
background+icon to error/onError so the fill and the glyph drawn on it
cannot drift apart.

The four category dots (deepOrange/amber/blueGrey/teal) stay literals,
with a comment saying why: they encode which category an item is, not a
theme role, and a single-accent system has no four distinct hues to lend
them. Mapping them onto primary/secondary/tertiary would make the
categories stop being distinguishable, which is their only job. They are
tracked in nielsen-audit.md as needing a categorical ramp. Note this
decouples the default dot from the old seed -- both were Colors.teal, so
they used to match by coincidence rather than by design.

Verified: 60/60 tests, 100% line coverage across 11 lib files, analyze
clean apart from four pre-existing unnecessary_unawaited infos. A
temporary assertion pumped the real app and read primary=ffb8862e and
surface=fff6f4f3 off a live Scaffold, confirming the palette changed
rather than merely compiling. Colour literals: 15 -> 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:35:08 +02:00
01778d4f05 Put focus_owner on the shared palette, and stop curfew hiding todo
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
focus_owner had drifted entirely off the frozen palette -- a cool blue
#5B9DD9 accent on a #1B1D21 field -- while its own doc comment claimed
it matched the other com.kuhy.* apps. It did not: every other app is on
the gold #B8862E. lib/theme.dart now aliases design_system-v0.1.0, so
the ~80 call sites keep reading the same names but the values come from
one place and cannot drift again. The six constants duplicated verbatim
in main.dart are deleted -- the old comment said they had been "lifted
out" into theme.dart, but the originals were never removed, so there
were two copies of a wrong palette.

Separately: dev.kuhy.todo was in the day allowlist but not the night
one, so a release build installed at 23:07 was hidden by the enforcer
~80ms later. It is now in night_allowed_packages. That is a deliberate
loosening of the curfew's "answer the phone, reach a bank, handle an
emergency" rule -- writing an idea down at night is the one thing that
app does -- and it is recorded as such in both config files.

Deploying the shell mirror of that list surfaced two pre-existing latent
bugs in phone_focus_mode/config.sh: comment lines inside the
double-quoted WHITELIST string referenced the night list (defined ~70
lines below) and an ALWAYS_BLOCKED that is never defined anywhere, so
under set -u the entire deploy aborted with "unbound variable". Both are
now written plainly, with a note saying why a dollar sign or a double
quote must never appear inside that string.

Verified on the Pixel 6a: analyze clean, 94/94 tests, colour literals
15 -> 2 (both Colors.transparent sentinels), and an ad-hoc assertion
confirming kAccent is really ffb8862e rather than merely compiling. The
installed APK shows CURFEW and the action links in gold, "Allowed apps
29 (16 at night)" (was 15) and "restored 1"; todo then launched and
rendered at 23:19, inside the curfew window, with all 108 notes intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:21:06 +02:00
f7e8c59e8f Add token_audit package: per-session token attribution and reporting
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:20:44 +02:00
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