Commit Graph

417 Commits

Author SHA1 Message Date
05d8c429d1 bucket_catch: share the must() DOM helper from src/test/
The eslint commit added must() to DropZone.test.tsx, which pushed that
file from exactly 250 lines to 256 - so it removed lint errors while
silently adding an over-cap file. Moving the helper to
src/test/domQueries.ts drops it to 229 and takes the session's over-cap
count to its intended 41 -> 39.

src/test/ is the right home: coverage excludes that path, which is why
the fixtures already live there. PuzzleResult.test.tsx now uses the same
helper instead of its own inline instanceof guard.

145 tests, coverage 100% with totals unchanged at 579/217/98/530, eslint
still at the 4 known remaining errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:57:53 +02:00
0df5e4ddad bucket_catch: make the done phase carry its result
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
Removes the last fixable non-null assertion. GamePhase goes from a string
union to a tagged one whose "done" arm carries `result: GameResult`, so
narrowing on phase.kind yields a non-null result and `fileResult!`
disappears. GameResult - previously an untagged union nothing consumed -
is now tagged, so the render picks a screen from the discriminant instead
of inferring it from which of two nullable states was set.

Three earlier attempts at this site were reverted: a null guard returning
null, a widened ReactElement | null return, and a tagged GameResult with
phase left as a string. Each either added a branch no test could reach -
dropping the 100% gate - or left the value nullable in the fall-through.
The invariant lived in the phase, so the fix had to as well.

Coverage stays 100%. Statements 585 -> 579 and lines 536 -> 530, exactly
the two useState declarations and their setters that the collapse
removed; branches unchanged at 217, so nothing new went untested. All 10
App tests pass unmodified - they drive the app through mocked onDone
callbacks, so both done arms are exercised through the new union.

eslint 5 -> 4 (45 -> 4 for the session). The remaining four are reported
in the previous commit's evidence. usePuzzleGameLoop's Map.get assertion
is not among the fixable ones: `?? Infinity` was tried and measured at
219 branches / 99.54%, since the default side is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:55:05 +02:00
e165ae8987 bucket_catch: 45 eslint errors -> 5, no suppressions
These were unmasked, not caused, last session: `lint` is
`tsc --noEmit && eslint src`, and a broken tsc had been short-circuiting
the && so eslint had never run on this package.

The binding constraint is the 100% coverage gate, not the linter. Most
idiomatic fixes for no-non-null-assertion add a branch that no test can
reach, which trades a lint error for a coverage failure - and the
no-suppressions rule forbids escaping either way. So every fix here is
one that adds no branch:

- no-non-null-assertion 18 -> 2: a waitFor already proves result.current
  is non-null, so capture it into a local instead of asserting on each
  read. App.tsx's files[0]! was pure noise (the same position also
  reported no-unnecessary-type-assertion). usePuzzleGameLoop's shift()!
  became index-then-shift, since the while condition already indexes [0]
  unasserted.
- non-nullable-type-assertion-style 9 -> 0: the rule-swap trap - eslint
  asks for `!`, which the other rule forbids. Narrowed with instanceof.
- unbound-method 10 -> 1: keep the MockInstance vi.spyOn already returns
  rather than detaching the method. This surfaced a latent bug: one test
  calls restoreAllMocks() mid-test, so the beforeEach handle was stale.
- no-unnecessary-type-assertion 5 -> 1, require-await 1 -> 0.

Coverage held at 100%: 584/217/98/535 -> 585/217/98/536. The +1 statement
and +1 line are the index-then-shift split; branches unchanged at 217 is
the number that proves no fix added an untested path. 145 tests, tsc 0.

The 5 remaining are reported, not silenced. App.tsx's fileResult! needs
the phase state to carry the result ({ kind: "done"; result }) - three
smaller structures all failed the same way. The rest are documented in
the evidence file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:48:45 +02:00
efb471fc8b usage_report: import _fmt_h from its owner, not through the render module
The previous commit's message claimed "no re-export barrel". That was
wrong: usage_report.py still did

    from _usage_report_render import _fmt_h, _render_report

and after the split _fmt_h lives in _usage_report_format - the render
module only re-imports it for _methodology_section. So the import reached
the owner through an intermediary, which is the exact pattern the brief
says was vetoed last session. Now each name comes from its own module.

Also untracks bucket_catch/packages/frontend/tsconfig.tsbuildinfo and
gitignores *.tsbuildinfo. It is a generated incremental-build cache
tracked in a repo that blocks binaries, and it has been dirty in every
commit this session, forcing pre-commit to stash each time.

143 tests pass; usage_report.py still generates a real report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:38:24 +02:00
ad646541ad usage_report: split _usage_report_render.py 310 -> 181 + 123 + 24
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
Verified by the 100%-coverage tests from the previous commit, written
before this seam existed.

_cpu_table/_dedupe_ram/_ram_table/_gpu_table move to
_usage_report_tables.py with the two constants they own. _md_escape and
_fmt_h move to _usage_report_format.py: both the tables module and the
render module need them, so a third module is what avoids a cycle.
Import sites name the owning module; no re-export barrel.

usage_report.py needed no edit - it imports _fmt_h and _render_report
from the render module, and both names still resolve there.

Evidence the move was verbatim: an AST comparison of all 10 functions
against HEAD reports 10 identical, 0 mismatches. That matters because I
retyped rather than cut-and-pasted, and did briefly introduce a literal
× where the original had × - ruff's RUF001 caught it.

Coverage stays 100% line+branch on all three (15/55/54 stmts, 4/10/14
branches, 0 missed). Test count 43 before and after, summed across the
five files it now lives in - the check that matters for a test split,
since a file outside the runner's glob is silently never collected.
Directory total 143 passed, unchanged.

Ran the real program: a full report from today's atop + pmon logs, every
section rendering through the new boundaries including the RAM sibling
dedupe and its (+20 more) truncation.

Over-cap 41 -> 39. No suppressions; a shared test-helper module was tried
and abandoned because name-tests-test would have needed an exclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:34:08 +02:00
97e114eec5 usage_report: cover _usage_report_render.py 21% -> 100%
43 tests, no production change - the module is byte-identical to HEAD.
This lands separately from the split that follows because a split
verified by tests written in the same commit proves nothing: the tests
would have been written against the post-split shape.

Covers every function, and specifically the branches a happy-path test
misses: zero-length window and cpu_count() -> None in _cpu_table,
non-positive RSS and the CPU/pid tie-break in _dedupe_ram, the sibling
truncation boundary in _ram_table, zero total_samples in _gpu_table,
OSError and unparseable MemTotal in _host_profile, and the single-sample
unknown-interval path in _methodology_section and _render_report.

_host_profile is the only impure function; its /proc reads go through a
monkeypatched Path.open and nvidia-smi is stubbed at the _run seam, so
no test touches the disk or forks.

117 stmts / 28 branches, 0 missed. linux_configuration/tests: 100 -> 143
passing. No suppressions: two ANN401s were removed by typing the helper
kwargs explicitly rather than silencing them, and a mypy ignore went away
with a fallback branch no caller reached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:20:02 +02:00
fc185b835f billsplit: drop the four unused platform runner directories
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
windows/ (18 files), macos/ (28), ios/ (40) and linux/ (10) were flutter
create scaffolding that nothing in this repo builds: billsplit-ci.yml runs
pub get, dart format, analyze, test and the Python coverage gate, with no
flutter build step for any platform, and no script or doc invokes one
either. 96 tracked files, 4188 lines.

This also removes win32_window.cpp (288 lines), the last over-cap C++
file. It could not be split honestly - flutter regenerates it - so
deleting the target it belongs to is the honest way off the list.
Over-cap: 41 -> 40, and the cpp category is now empty.

Unpicked all three references: the .binary-allowlist icon entries and the
.gitignore un-ignore rules that existed only to let those icons past the
binary block, plus the README claim that the repo ships without platform
runner folders (false while 96 were tracked, true again now).

The four dirs are now gitignored so `flutter create` regenerates them
locally without re-tracking them. Verified by actually running
`flutter create . --platforms=linux,windows,macos,ios`: git add -An then
refuses all four as ignored. analyze/test/format stay green, 60 tests.

analysis_options.yaml is deliberately untouched - flutter analyze rewrites
the platform excludes back on every run, so removing them is not durable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:14:32 +02:00
e58eec2b6a billsplit: fix the two CI gates that were already red on main
flutter analyze --fatal-infos exited 1 at 6713ef3 with four
unnecessary_unawaited issues in lib/ui/home_screen.dart: Flutter now
annotates Navigator.push with @awaitNotRequired, which makes the
unawaited() wrappers redundant. Removed all four wrappers and the
dart:async import they were the only user of.

dart format --set-exit-if-changed was separately red on three test files
left unformatted by a68313f. One trailing blank line each.

Both are steps in .github/workflows/billsplit-ci.yml, so billsplit CI was
failing on main for reasons unrelated to any current work. Fixing them
first means the platform-directory deletion that follows can be verified
green-to-green.

flutter test: 60 passing before and after - the unchanged count is what
proves the unawaited() removals did not shift navigation timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:11:04 +02:00
da1e0d3588 atop_agg: split atop_agg.c 478 -> 204 + state + emit
Three units along the file's own concerns:

  atop_agg.c (204)        ingest -- tokenising atop output, folding lines, main
  atop_agg_state.c (113)  the per-PID hash tables, slot lookup, epochs, alloc
  atop_agg_emit.c (178)   the ranked, name-aggregated report

Table geometry and the four table functions that now cross a unit boundary go
in a private atop_agg_internal.h. Those four lose `static` -- the one
visibility change here.

The Makefile edit is the load-bearing part: SRC had to grow or the build would
have linked the old single unit and the suite would have passed with the new
files unused. Checked by reading the compile line, not the exit status.

I had written this file off as unverifiable earlier. Wrong: the Makefile has
had test and coverage targets all along. Coverage is 92.9% lines / 100%
functions on the same 211 source lines, before and after -- the best verifier
of anything left over the cap. Production binary builds at -O2 and runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:49:46 +02:00
e6b9c730f2 focus_owner: split FocusPolicy.kt 277 -> 238 + types
Moved the four declarations the policy is built from -- the schema version,
PolicyFormatException, CurfewWindow with its midnight-wrapping contains(), and
HomeLocation with its hasCoordinates guard -- to PolicyTypes.kt (48). Same
package, so no call site and no import changed.

Not the loader, though it is the bigger block: FocusPolicy.parse is called at
eight sites, four of them in tests, and making it top-level would rewrite all
of them. That also corrects an earlier reading of mine -- a declaration-name
scan had `parse` down as untested, but every fixture in EnforcementFixtures.kt
goes through it. The scan undercounts transitive exercise, so the seam, not the
file percentage, is what decides these.

40 gradle tests and 94 flutter tests still pass, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:45:27 +02:00
b6a82093f2 usage_report: split test_usage_report_since.py 480 -> 186 + 145 + 178
Split three ways on the file's own section banners, which already grouped the
tests by concern:

  test_usage_report_merge.py (145)  aggregate and window merging, PID carry-over
  test_usage_report_since.py (186)  pmon filtering, atop bounds, state, planning
  test_usage_report_modes.py (178)  start resolution, dispatch, PRC HZ, native

6 + 13 + 11 = 30, the pre-split count -- the check that matters, since a file
that failed to import would collect nothing and still report success. Also
verified a negated assertion still fails, so none of the three is vacuous.

Only the test file is split. The usage_report SOURCE files stay over cap on
purpose: measured coverage is 21% for _usage_report_render.py, 39% for
_usage_report_parsing.py and 47% for usage_report.py, so a split there would
move code no test runs and pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:40:06 +02:00
6a019633e5 phone_focus_mode: split test_adb_common.sh 287 -> 134 + 105
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
Mirrors the source split in the previous commit: the six tests exercising
adb_verify_trusted_identity and adb_forget_trusted_device move to
test_adb_trusted.sh, beside the adb_trusted.sh they cover.

Shared fixtures go to adb_test_harness.sh (76) -- counters, run_test, the adb()
mock, the injection payloads, and the XDG_STATE_HOME redirect into a mktemp
dir. That redirect had to travel with the mock: a test file sourcing the
library without it writes to live phone state.

10 + 6 = 16, the pre-split count. That sum is the check that matters -- a
dropped test or a file that failed to source its fixtures would lower the
total while both files still printed success. Also verified both exit 0
explicitly (the summary line prints either way), and that a copy with one
assertion replaced by `return 1` still exits 1, so neither file is vacuously
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:34:55 +02:00
466b074dd7 phone_focus_mode: split adb_common.sh 346 -> 155 + two libs
adb_trusted.sh (136)  the enrolled-device record: save, list, forget, match,
                        and refuse to act on an unenrolled phone
  adb_locking.sh (76)   the single-instance run lock and the cooldown markers

Both are sourced at the end of adb_common.sh from its own directory, so every
consumer keeps getting the whole API from the one source line it already has.
No consumer was edited. Nothing deploys a copy of this lib elsewhere, so the
deployed-copy blocker that stops install_leechblock.sh does not apply.

Picked the seam by coverage, not size: 13 of 23 functions are named by
test_adb_common.sh, and both extracted groups sit inside that covered set.

One real edit. _matches_trusted_record used to shadow TRUSTED_DEVICE_FILE with
a `local` so _load_trusted_device_values would read it; across a file boundary
shellcheck can't see that and reported SC2034. Fixed by making the function
take the path as an argument, which is what the caller meant -- not by a
suppression. shellcheck passes with none added, at a hook that runs with no
severity filter.

16/16 tests still pass, and sourcing adb_common.sh alone still defines every
function from all three files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:31:34 +02:00
4dcc0beee1 focus_owner: split EnforcementDecision.kt 274 -> 221 + types
Moved the four value types the decision is expressed in -- HideReason,
Geofence, EnforcementReason and EnforcementInputs -- to EnforcementTypes.kt
(63). No import anywhere changed: same package, and Kotlin resolves those
across files, so no consumer and no test needed touching.

Picked this file by measuring, not by line count. Comparing every declaration
against the text of the test sources gives EnforcementDecision 66%,
FocusPolicy 35%, EnforcementRunner 31%, DevicePolicyBridge 3%. Only this one
has a test suite strong enough to verify a split; DevicePolicyBridge is an
Android DevicePolicyManager wrapper JVM tests cannot reach at all.

40 gradle tests (13+12+9+6) and 94 flutter tests still pass, analyze clean.
Note --rerun-tasks: a plain gradlew test reports UP-TO-DATE and proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:26:18 +02:00
7ff048be1c bucket_catch: split usePuzzleGameLoop.test.ts 332 -> 125 + 210
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
Eleven tests split by theme: the five that assert the loop declines to start
or tears down cleanly stay put; the six that actually play the game move to
usePuzzleGameLoopPlay.test.ts.

Fixtures went to src/test/puzzleLoopFixtures.ts (62) -- that path is required,
not stylistic, since coverage excludes src/test/** and a fixture beside the
hook would be counted as production code and break the 100% gate.

flushTick could not move as-is: it closed over the describe block's
rafCallbacks. It is now installRafHarness(), which owns the queue and hands
back a bound flushTick, so the two suites cannot leak frames into each other.

The check that matters for a test split is the pair (15 files, 145 tests):
file count up by one, test count unchanged. A name outside vitest's glob would
have passed just as green with six tests never running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:07:38 +02:00
3f4d8d4e90 bucket_catch: split usePuzzleGameLoop.ts 386 -> 194 + two modules
puzzleGeometry.ts (127)  board constants, ScheduledPiece, and the Union-Find
                           interval clustering in assignXPositions that makes a
                           100% score achievable; plus aabbCollision
  puzzleRender.ts (110)    the four canvas draw functions

Both were module-private, so nothing outside the hook sees either seam.

One thing was reshaped rather than moved: the inline clearRect+gradient in the
tick became a named drawBackdrop, so the render module owns every canvas call.
That is why coverage goes 583/217/97/534 -> 584/217/98/535 -- one statement,
one function, branches unchanged.

eslint reports 45 errors, exactly as it did before the split, which is the
check that matters here: the extraction added none. Those 45 are pre-existing
and were unmasked by the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:05:37 +02:00
394c919f9a bucket_catch: fix typecheck so build works and eslint runs
npm run build and npm run lint were both failing at HEAD on two errors:

  TS2769  vitest 4 no longer contributes `test` to vite's defineConfig, so
          the /// <reference types="vitest" /> directive had gone inert
  TS6133  an unused `const { result } =` in usePuzzleGameLoop.test.ts

Because lint is `tsc --noEmit && eslint src`, the failing tsc short-circuited
the &&, so eslint had never actually run on this package. Fixing tsc unmasks
45 pre-existing eslint errors across 10 files -- 18 no-non-null-assertion,
10 unbound-method, 9 non-nullable-type-assertion-style, and a tail. Those need
per-site judgment and are NOT fixed here; lint still exits 1 on them.

So this does not make lint green. It makes build work (52 modules, was
failing) and makes the eslint failures visible instead of hidden behind a
short-circuit. Coverage unchanged at 583/217/97/534, which is what proves the
config is still read after changing its import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 10:02:58 +02:00
c2760ef5e3 Split reverse_survivors types.ts 295 -> 157 + roster + statuses
The 82-line ENEMY_SPECS table and its kind unions are data, not vocabulary --
that is the block you edit to balance the game. Moved to enemies.ts (143).

statusKinds.ts (25) is not cosmetic: enemy specs name the status they inflict
and the survivor carries seconds-remaining per status, so leaving StatusKind
in types.ts would have made types.ts and enemies.ts mutually dependent. It
imports nothing, so the graph is statusKinds <- enemies <- types.

14 files repointed by script rather than by hand, splitting each types import
across the three owning modules by symbol.

The tests cannot catch a mistyped stat -- they assert behaviour, not the
balance table, so a wrong enemy hp passes 160 tests at 100% coverage. Checked
separately: 191 numeric pairs before, 191 after, compared as sorted multisets,
identical. check and build both pass; bundle 210.11 kB, same as baseline.

No reverse_survivors file is over the 250-line cap now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:57:26 +02:00
1342a60a65 Split reverse_survivors sim.ts 361 -> 69 + three modules
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
reverse-survivors CI / check (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Split along actor lines, but not the obvious way. A plain sim/enemies cut
needs makeEnemy and grantXp going out and enemiesStep coming back -- a cycle.
Nothing here would have caught it: no eslint-plugin-import, and tsc and vitest
both pass on cyclic ES modules. It shows up as a TDZ error in the bundle.

So the graph is a tree instead:

  simUnit.ts (42)      leaf -- geometry, projectile constants, makeEnemy,
                       enemySpeed; imports only types and vec
  simSurvivor.ts (132) createSurvivor, xp/upgrades, survivorStep
  simThreats.ts (168)  enemiesStep, projectilesStep, and enemy death
  sim.ts (69)          createInitialState and step, the only module that
                       reaches all three

Repointing director.ts's makeEnemy at the leaf also removes the sim <->
director cycle that was already there, surviving on const-arrow late binding.

npm run check and npm run build both pass -- 160 tests, coverage identical at
531/159/101/514, 32 modules bundled at 210.09 kB against 210.11 kB before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:52:19 +02:00
44b8b79207 Split kcd2 App.tsx 306 -> 217 + useInventoryState
App.tsx owned the inventory as well as rendering it: resolving a shared link
against this browser's storage at mount, deferring persistence until the
visitor consents, clearing the URL fragment, and three mutators. Moved all of
it to a useInventoryState hook (163) beside the existing useSolver.

The two inline handlers that only set state became named keep/replace
callbacks, so the view no longer touches setPersist or setSaved; Clear,
Discard and onImport all go through replace.

Coverage held at 100% -- totals moved 863/375/181/810 -> 864/375/182/811,
exactly the one statement and one function that promoting the Keep handler
adds. Branches unchanged, so nothing conditional left test reach. That was
the metric that would have caught an over-large extraction here, since the
hook has no dedicated test and rides on the four App test files.

No kcd2_dice_solver file is over the 250-line cap now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:46:49 +02:00
47996ac017 Split kcd2 badgeValue.ts 334 -> 172 + baseline
The module did two things: measure what a badge is compared against, and value
each badge against it. Moved the first half to badgeBaseline.ts (185) -- the
shared types, measureBaseline's 4,000-turn simulation, turnValue,
bestThrowsValue, singleCharge, rankValue and the context defaults.

badgeValue.ts (172) keeps valueBadge's per-effect switch and recommendBadges.
The dependency runs one way, and valueBadge's default parameter still calls
measureBaseline, so callers that omit a baseline behave as before.

solve.ts and badgeValue.test.ts were repointed, import paths only. Coverage
totals unchanged at 863/375/181/810, so every arm of the valuation switch is
still exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:43:06 +02:00
d39ad67323 Split kcd2 scoring.ts 361 -> 172 + move generator
The 114-line private moves() method was most of the file: it enumerates every
legal step from a position -- straights, badge formations, n-of-a-kind, lone
1s and 5s, and the two joker rules -- while Scorer just maximises over it and
caches the answers.

Extracted it to scoringMoves.ts (203) as a free function over ScoringConfig,
taking combinationValue with it (its only caller, and the home of the
Emperor/Tyche triple-only rule) plus the tripleBase/ofAKindValue helpers.
scoring.ts (172) keeps Scorer, its two caches and its two recursions.

moves() was private, so no consumer saw the seam; only scoring.test.ts moved,
for the two value helpers, import path only. Coverage totals are unchanged at
863/375/181/810 -- the generator's branches are all still exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:39:31 +02:00
1d833d7524 Split kcd2 search.ts 417 -> 100 + two modules
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
search.ts held three separable concerns. Extracted two:

  searchGroups.ts (109)     the vocabulary -- InventoryEntry, DiceGroup,
                            Selection -- plus groupInventory, countCandidates
                            and expand
  searchStrategies.ts (232) Leaderboard and both strategies: exhaustive
                            enumerateAll, and hillClimb with its greedy and
                            random seeds

search.ts (100) keeps findBestSet, which picks between the strategies.

Import sites were rewritten to name the owning module rather than leaving a
re-export barrel in search.ts, so two test files changed -- import paths only,
no assertion touched. DiceGroup and Selection had to become exported to cross
the module boundary.

Function bodies moved verbatim: 288 tests still pass and coverage totals are
identical at 863/375/181/810, not merely still 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 09:26:42 +02:00
5dce82f2c7 Remove the dead Thorium/Fitatu autostart instead of splitting it
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
setup_thorium_startup.sh (443 lines) was queued for a split under the 250-line
cap. It turned out the feature it installs has been broken since Thorium was
removed in favour of chromium/librewolf.

/usr/local/bin/thorium-browser is now only a leftover symlink to
browser-preexec-wrapper, which routes by basename to /usr/bin/<name>. With no
/usr/bin/thorium-browser it falls into its testing branch and treats the URL as
the binary, so every boot logged:

  thorium-browser: line 33: /https://www.fitatu.com/app/planner: No such file

while systemd reported status=0/SUCCESS and "Finished". The success status is
why this went unnoticed.

Splitting a 443-line script to keep a dead feature under a line cap is the
wrong work, so it is deleted instead, along with its checker.

Removing check_thorium_startup() from check_and_enable_services.sh is the part
that makes this stick: its report_and_fix RE-RAN the installer whenever the
service was not enabled, so deleting only the deployed units would have let
them come back. Verified afterwards that the hourly maintenance timer runs a
deployed script with no thorium reference, and that nothing on a timer invokes
the checker at all.

Also removed from fresh-install/main.sh so new machines skip it. Units disabled
and all four deployed files removed. The generic browser-candidate lists that
merely mention thorium are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 08:08:09 +02:00
4eedc17b17 Split install_pacman_wrapper.sh into an entry plus four libs
The 316-line installer was 66 over the 250-line cap. The managed-copy helpers,
the /usr/local/bin install step, the integrity + drift manifest, and the
immutability/LeechBlock/VirtualBox payloads move into a sibling lib/, leaving a
128-line entry.

What stays in the entry is chosen by what the harness can execute, not by
taste. `--bind-abs /usr/bin` removes the directory bwrap needs to exec
anything, so the trace stops at `ln -sf ... /usr/bin/pacman` with exit 1 on
EPERM. That line onwards, plus the `if [ ! -L /usr/bin/pacman ]` backup branch
whose guard is false on this machine, therefore never run under trace — moving
them would have shipped code no diff could check. They stay put.

Everything else is proven: the before/after traces are identical, including all
16 generated-file hashes. pacman_wrapper at 548fc2b53ee4cea6 is the one that
matters most — it is post-`sed -i`, so it catches a reorder of the rewrite
relative to the copy.

The path definitions stay in the entry too, for a linter reason rather than a
runtime one: a definitions-only lib assigns without referencing, which is
SC2034 on 36 variables, and suppressions are not allowed. Libs that only
reference globals are fine. `export` would have silenced it while leaking 40
variables into every child process, which the trace cannot see.

Eight assertions across two test libs grep the installer's source text; five
needed widening to search entry+lib/. TEST 9's `chattr +i` grep caught the move
as a genuine failure first, which is what prompted enumerating the rest. No
assertion was weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 07:42:31 +02:00
26965bad32 Split install_usage_monitoring.sh into an entry plus five libs
The 290-line installer was 40 over the 250-line cap. Its five banner-marked
steps move into a sibling lib/, leaving a 53-line entry that owns log()/die(),
the globals that cross between steps, and the call order.

Proven by execution, not inspection: meta/scripts/trace_shell_split.sh
--prefix was captured at HEAD in place (tracing from a detached worktree
changes REPO_DIR, which is interpolated into a generated file, and fakes a
diff), then re-run after the split. The traces are identical -- same exit
status, same ordered stubbed calls, and all five generated-file hashes
unchanged, including usage-report-catchup.sh at sha=9f4803ab157c5527.

REPO_DIR is computed once in the entry and read as a global rather than
recomputed inside a lib one directory deeper; recomputing it would have
pointed the generated catch-up script at the wrong repo while still exiting 0.

test_usage_monitoring_installer_efficiency.sh greps the installer's source
text for the pmon heredoc, so it follows that heredoc to lib/nvidia_pmon.sh.
Its extraction anchor also had to become whitespace-insensitive: the
pre-commit hook runs `shfmt -w` on staged shell files and rewrites
`cat > "x" << 'SCRIPT'` to `cat >"x" <<'SCRIPT'`, which silently broke
extraction. All five property assertions are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 07:26:56 +02:00
19e652dc30 Close three fail-open paths in the prefix trace harness
All three were found by review and reproduced before fixing. Each let a run
report success while proving nothing -- the shape this harness exists to
prevent.

A reused prefix defeated dropped-write detection entirely. Run 1's files were
still there when run 2's manifest was taken, so the manifest reported writes
that never happened and the fixture pair diffed CLEAN. The doc's own examples
demonstrated that workflow. A non-empty prefix is now refused -- not deleted,
since the path is user-supplied -- and the examples use distinct prefixes.

A missing bwrap was swallowed. trace_prefix_bwrap_argv returned 1, but the
caller reads it through `mapfile < <(...)` and a process substitution's status
is discarded, so runner ended up empty and the run continued with no sandbox
and no uid 0. Same swallow as the grep-status one fixed earlier, one layer up.
bwrap is now probed in validate_requirements, before anything runs.

The absolute-path scan only fired when no --bind-abs was given at all, so
binding one path and missing three produced no warning. It now runs whenever
--prefix is given and reports only what is not already covered. Fixing that
surfaced a second defect: --bind-abs on a FILE aborted with "Can't mkdir
/etc/profile: Not a directory", and binding its parent reintroduced the
wholesale /etc shadowing this change had already measured as harmful ($SUDO_USER
collapsing to /home//, the real /etc/profile unreadable). Files are now bound
file-over-file, seeded with a copy so a script that greps before appending sees
real content.

Also measured rather than argued: the XDG redirect. A new fixture run with a
real XDG_DATA_HOME pointing at a decoy shows all four writes landing in the
prefix and the decoy empty -- the rsync --delete data-loss path, proven closed
without running install_leechblock.sh. And no-prefix output was diffed against
cdf2a55 from a detached worktree: byte-identical, so existing baselines hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 23:38:38 +02:00
f6356d4725 Trace scripts that place files, via a prefix redirect
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
Shell redirections are builtins, so no PATH stub can intercept
`cat > /etc/modprobe.d/x.conf`. That left installer scripts unverifiable:
stub cp/chmod/ln and the trace is nearly empty, don't and the run rewrites
the live system.

--prefix redirects HOME and every XDG_* base into a temp tree. XDG_DATA_HOME
is exported explicitly, not left to default off HOME: install_leechblock.sh
reads it directly and then runs `rsync -a --delete` at it.

--bind-abs binds hardcoded absolutes via bwrap --unshare-user --uid 0, leaf
directories only. uid 0 is load-bearing beyond permissions: common.sh's
require_root does `exec sudo "$0"`, and under the sudo stub that truncated
the whole run to one recorded call -- a trace that diffs clean against any
split. Binding /etc wholesale was measured to shadow /etc/passwd, collapse
$SUDO_USER to /home//, and mask a nested bind, so the scanner never emits it.

The trace gains a manifest of every file written, with content hashes. That
section is the point: with it, a dropped write in the fixture pair is a
one-line diff; with it stripped, the two traces are identical, i.e. the old
harness passed that broken split. Hashes normalize the prefix to @PREFIX@
because install_usage_monitoring.sh writes an unquoted heredoc that bakes
$HOME into the artifact.

Verified: ~/.config/systemd/user, ~/.local/bin, ~/.local/share and
/etc/modprobe.d byte-identical before and after; usage-report units still 2.
The three prior regressions are now committed fixtures rather than ad-hoc --
one was silently neutered mid-change when a trailing echo added for SC2034
became the function's last statement and turned exit 1 into exit 0.

Stub generation moved to lib/ because --prefix pushed the harness to 252
lines, over the cap it exists to serve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 23:31:49 +02:00
0a14c451d7 Resolve the symlink in lint_python.sh's SCRIPT_DIR
The previous commit broke `./lint_python.sh` from the repo root. That path
is a symlink into meta/, and `dirname` does not resolve symlinks, so
SCRIPT_DIR became the repo root and the new `source "$SCRIPT_DIR/lib/..."`
line pointed at a lib/ that does not exist -- an instant exit under
set -euo pipefail.

The bug was latent before the split: the same unresolved SCRIPT_DIR only
fed PROJECT_ROOT, so a root-path run degraded quietly. Adding a source
line is what turned a wrong value into a hard failure.

It was invisible because every check I ran used `bash meta/lint_python.sh`
-- the one entry point that could not fail. Both paths are now verified:
`./lint_python.sh` exits 0 through the symlink, and the direct invocation
still diffs byte-identical against the pre-split baseline. No CI job, hook
or wrapper calls it, so nothing automated was broken in the interim.

Recorded in docs/shell-split-verification.md as its own trap, with the
commands to enumerate a script's other entry points before splitting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 23:13:07 +02:00
97a722d2b2 Split lint_python.sh: output helpers and the full-mode linters
353 -> 175 + 116 + 96. The print_* helpers, run_tool, check_tool and the
--help text move to lib/lint_output.sh; everything that runs after
--quick would have exited moves to lib/lint_full_tools.sh as
run_full_mode_linters. The output lib is sourced before argument parsing
because --help now calls usage() from it.

Verified on the path that actually contains the change: a full-mode run
diffed byte-identical against a detached worktree at the pre-split
commit, with pylint, flake8 and pycodestyle in the output proving the run
entered the extracted block. The --quick comparison is also identical but
exits before that block, so it alone would have proved little. The
failure path returns 1 on an unformatted file, confirming OVERALL_STATUS
still propagates out of the function.

Two shellcheck findings fixed rather than suppressed: SC2034 on
OVERALL_STATUS (the lib is analysed standalone, so the cross-seam
variables are now declared with a comment), and a pre-existing unquoted
`exit ${OVERALL_STATUS}` that the commit hook's stricter settings
surfaced. lint_python.sh was shfmt-clean before and still is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 23:08:02 +02:00
4ff7a0bf15 Split mtk_root run_tests.sh: assertions and the dump-test group
421 -> 218 + 157 + 75. The assertion helpers (pass, fail, assert_*,
strip_noncode, run_recon) go to lib/assertions.sh; the six
20-dump-stock.sh tests go to lib/dump_tests.sh. run_tests.sh keeps the
counters, the trap, the eight recon tests and main().

Verified by running the suite, not by inspecting it: 58 passed / 0 failed
before and after, and a diff of the complete 89-line output reports no
differences. The suite is hermetic -- every test drives a fixture via
MTK_ROOT_FIXTURE -- so this is a real execution of 58 assertions rather
than a stubbed trace.

All three files are shfmt-dirty, but run_tests.sh already was at HEAD
(2-space indent against shfmt's tabs) and the libs inherit that style, so
no clean file was made dirty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 23:02:09 +02:00
1d03b508a7 Let trace stubs emit values, so traces reach guarded branches
A stub that prints nothing makes `size=$(du -sk x)` empty, so every
`((size > 0))` is false and the trace walks past every guarded branch.
Two such traces compare identical while exercising none of the changed
code -- a green check that proves nothing, which is the exact failure
mode this harness exists to prevent.

--stub now takes `name=output`. Measured on a fixture whose `sudo rm -rf`
is guarded by a size test: the silent stub gives `size=[]` / "branch
skipped" and never records the rm; `--stub 'du=4096\t/some/dir'` gives
`size=[4096]` / "BRANCH TAKEN" and captures it.

This weakens the earlier disk_cleanup_check.sh trace, which used a silent
du stub: that trace only proved the empty-size path identical. The real
run from /tmp is what covered the populated path. Noted in the evidence.

Docs also record two limits found while surveying: file-placement
installers are not yet traceable (stub cp/chmod/ln and the trace is
empty; don't and it rewrites the live system), and baselines for scripts
reading relative paths must run from the script's own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:59:40 +02:00
5b6973e11d Split disk_cleanup_check.sh: extract the helpers and the output blocks
329 -> 230 + 124. The colour constants, human_readable, dir_size_kb,
report, confirm and try_clean move to lib/disk_cleanup_common.sh, along
with the report-only loop and the summary block as print_report_only and
print_summary.

The seam passes state -- report accumulates into TOTAL_RECLAIMABLE and
confirm reads CLEAN -- so both are set before the source line, and the
verification is a trace diff rather than a static check: identical exit
status, identical ordered du calls, identical stdout and stderr against a
detached worktree at the pre-split commit.

Also ran it for real from /tmp (not its own directory) to confirm
SCRIPT_DIR resolves the lib from any cwd: 303.7 GB reported reclaimable,
nothing deleted. The one shellcheck finding (SC2034 on RED) was fixed by
moving the function that uses it, not suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:57:06 +02:00
0734e06ca5 Let trace_shell_split.sh stub extra binaries per run
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
fresh-install/main.sh calls git clone against live AUR URLs, makepkg, and
curl -- none of which belong in the default stub set, since plenty of
scripts read git state harmlessly and a blanket git stub would change what
they see rather than protect anything.

--stub git,curl,makepkg shadows them for one run. Verified: both calls are
captured in the trace with no repo cloned and no network hit, and the two
regression fixtures still behave (exit 1 for the set -e tail, four
circular-name-reference warnings for the nameref).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:51:20 +02:00
f4d45ce60f Add an execution-trace harness for shell splits
verify_shell_split.sh sources the libs and never calls them, which is why
analyze_repo.sh shipped broken after passing every static check. This runs
the script for real with ~30 mutating binaries (sudo, pacman, systemctl,
adb, nft, mount, reboot...) shadowed by PATH stubs that record their calls
and exit 0, then prints a diffable trace: exit status, stubbed calls in
order, stdout, stderr.

Verified against both bugs the handoff documents: it catches the `set -e`
function-tail abort (exit 1, later calls absent from the trace) and the
self-referencing nameref (exit 0, correct stdout, `circular name
reference` in stderr). `systemctl is-active foo` stayed inactive across
all three runs, so nothing reached the live system.

This is what lets Decision 6 ("never execute an enforcement script") and
the user's "run-and-diff always" coexist. Handoff updated with both, plus
the win32_window.cpp resolution: it is tracked and listed in
CMakeLists.txt, so gitignore cannot work -- the exemption belongs in
~/utils/file_length/config.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:49:48 +02:00
3b37d7b422 Split the fix-selection tests out of EnforcementLogTest
EnforcementLogTest was 251 lines and covered two unrelated subjects: the
log itself, and EnforcementRunner.best's fix-selection ordering. The six
best() tests move to EnforcementRunnerBestTest; 251 -> 169 + 90.

The shared candidate() builder is hoisted into the existing
EnforcementFixtures object rather than copied, and EnforcementLogTest now
takes homeLat/homeLon from there too -- the home coordinates should exist
in one file, given this suite asserts they never reach a log record.

Verified by running, not just building: 40 tests, 0 failures, 0 skipped,
matching the documented focus_owner Kotlin baseline; the original 15 are
preserved as 9 + 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:46:51 +02:00
a5a2a41046 Commit the Flutter-generated analyzer exclude in focus_owner
`flutter pub get` rewrites analysis_options.yaml to exclude build and
platform directories ("Upgrading analysis_options.yaml to exclude build
and platform directories"). Reproduced byte-identically from a clean
detached worktree, so the block is tool-generated and idempotent -- it
reappeared as an uncommitted change in every session until now.

dart analyze is clean both with and without it; the exclude is a
forward-looking guard against platform-dir noise, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:41:19 +02:00
c4bdaad038 Split the last two over-cap shell test suites
test_pacman_wrapper_security.sh (287) and test_i3blocks_efficiency.sh
(345). Both keep their filenames -- shell-tests.yml runs them by name --
and both were verified by diffing a full run against the pre-split code:
byte-identical output, exit 0 either side.

The i3blocks setup half stays in the entry on purpose. Its five fake
$BIN_DIR binaries are written with heredocs, and a seam inside a heredoc
yields a lib that will not parse -- the worst failure this effort has
produced so far. Only the two heredoc-free regions moved.

Both i3blocks lib functions end in an explicit `return 0`. Their last
statement is a `|| fail ...`, which as a function tail under set -e
becomes the return value and would abort a run in which every check
passed. `fail` exits on its own, so reaching the end means success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:27:44 +02:00
656375275b Split test_security_hardening.sh; finish the mutation-driver split
Two files under the cap.

test_security_hardening.sh (423) keeps its filename -- shell-tests.yml
runs the suite by name -- and its five sections move into three sourced
libs. They are top-level blocks, not functions, so each is wrapped and
called in the original order; the counters and LIVE_SECTION stay in the
entry, which sourcing shares. Verified by diffing a full run against the
same suite from a detached worktree at HEAD: byte-identical output, same
exit status, still 18/1/5 on this host. The one failure is pre-existing.

Linting a lib alone turns a bare LIVE_SECTION=1 into SC2034, since its
only reader is test_result in the entry. A live_section() setter makes
the write a real reference, so no suppression was needed.

The mutation-driver split was already in the tree from the previous
session and did not run: the operators had moved to
_session_autopsy_mutations.py but the driver still named Mutation
without importing it, so mypy and pylint both reported undefined-variable.
Restored the import; the script now reports all 12 tests killed.

block_compulsive_opening.sh (705) is deliberately untouched and now says
so in the contract. install_all copies the running script to
/usr/local/bin, so an entry sourcing lib/ would resolve SCRIPT_DIR there,
find nothing, and leave beeper, signal-desktop and discord unlaunchable
with a failing pacman hook. Splitting it means teaching the installer to
copy the libs -- a refactor, not a line-count split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:24:02 +02:00
1506371976 Split calc-live.zsh: extract the qalc coprocess
266 -> 204, with the warmed qalc coprocess (its four _CALC_Q* state vars
and four functions) in calc-live-qalc.zsh at 74.

The source line uses ${0:A:h}, matching what the file already does for
_CALC_EVAL_PY. That is load-bearing here: this file is loaded as
~/.oh-my-zsh/custom/calc-live.zsh, a symlink into the repo, so a naive
$0 would look for the sibling in the oh-my-zsh custom dir, where it does
not exist -- and break every new shell, not just one script run.

So it is verified THROUGH the symlink, not the repo path: sources clean,
functions defined across the seam, and the qalc path in the new file
answers end to end ("=5 ft to cm" -> 152.4 cm). Seven expressions give
byte-identical results to HEAD, and _calc_is_expr still accepts "2+2"
while rejecting "ls -la".

zsh -n is the parse check here; shellcheck cannot read zsh and the
repo's hook excludes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 22:12:32 +02:00
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