The PC no longer wakes anyone -- the phone does. This deletes the alarm
window, the audio escalation, the sink/reactivation machinery, the power-key
inhibitor, the escape hatch, the curtains, the smart plug, the brightness and
DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook)
and the challenge set: 30 modules, 34 test files, ~5k lines.
What stays is the part that still earns its keep: the per-minute trigger
reads the alarm the phone published and starts morning-routine.service, so
the workout lock still runs at the right time.
THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only
writer of wake_state.json, and _trigger gated on it via
was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today()
already permanently False, since WORKOUT_LOG_FILE does not exist -- both
gates would have been dead and the routine restarted on every tick for the
rest of the 60-minute catch-up window. The unit is Type=simple, so a start
while it runs is a no-op, but the moment the workout lock finished the next
tick would relaunch it on top of the user. So _trigger now writes its own
once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates
on that. Collapsing the catch-up window instead was rejected: that window is
the entire reason a suspended or just-booted machine still fires. It also
replaces the deleted sleep hook, via Persistent=true.
_trigger also imported should_refire from _alarm_session, which was on the
delete list. Since start_session lived only in _alarm.py, no session record
is ever written again and the branch was unreachable, not merely unused.
WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file.
The trigger tests never redirected it, because until this commit nothing on
that path wrote anything -- and a per-file redirect is exactly the kind that
gets forgotten when a new writer appears, with a silent failure: a green
suite that quietly stamped the live state file. Verified by mtime that it
did not.
Cross-repo, same change: morning_routine._orchestrator loses its alarm leg
and its --with-alarm flag. Leaving an orchestrator pointing at a deleted
module caused a month-long silent failure once already.
uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown
is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave
it in place, and with the alarm cache gone it falls through to rtcwake +
hibernate -- so "shutdown now" would silently stop being a poweroff, every
day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed:
screen-locker's own _wake_alarm.py still needs passwordless rtcwake.
159 tests pass at 100% branch coverage; all 26 pre-commit hooks green.
Verified by running it: _sync --show and _trigger --dry-run both resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
Already red before today's Python work touched the repo; the commit hook had no
reason to look at requirements.txt, so only the CI mirror's whole-tree run saw
it. Bumped across every repo pinning 7.15.4 in the same pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
**The two shared Python libraries were four minor versions behind and the gate
could not see it.** They are PEP 508 direct references
(`gatelock @ git+...@gatelock-v0.4.1`), which parsed as unpinned PyPI packages
that do not exist on PyPI -- an undeterminable answer, not a stale one.
utils@7a682a4 teaches the gate that shape; this repo is the first to benefit:
gatelock v0.4.1 -> v0.7.2, crdt-sync v0.5.1 -> v0.9.1. The system install was
already on 0.7.1/0.9.0, so only the manifests were adrift.
Python side: bandit 1.7 -> 1.9.4, mypy 1 -> 2.3.1, pylint 3 -> 4.0.7,
pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, pytest-randomly 3 -> 4.1.0,
ruff 0.8 -> 0.16.5, python-kasa 0.7 -> 0.10.2, and the rest exact-pinned.
Five majors and not one lint or type finding: `scripts/ci_mirror.sh` builds a
clean venv from requirements.txt and runs the whole CI set, 465 tests at 100%
branch coverage.
phone_app takes crdt_sync_dart v0.11.1 and the flutter_secure_storage 11 chain
(compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1). 79 tests green, analyzer clean,
release APK builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
The dismiss-code entry rendered normally every alarm morning but accepted
no keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips
near 07:00 and 07:55 each day in the journal. Root cause was upstream in
gatelock: LockWindow only focuses the entry once, on first grab
acquisition, so a surface rebuilt after an output recovers gets a widget
nothing ever re-focuses -- fixed in gatelock-v0.4.1
(kuhyx/utils@92f55ce).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
The seventh and last app on the GitHub-repo transport. fetch_remote_alarm()
is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the
mirror later is deleting one branch and three constants.
Two things made this look harder than it was. The first was the standing
"_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service
runs /usr/bin/python rather than a venv. But the package already depends on
gatelock and python-kasa, and _smart_plug.py already has the pattern for
exactly this -- a module-scope import inside try/except ImportError. So the
Firebase import is guarded the same way: a dependency that failed to install
degrades the read to GitHub instead of killing the per-minute trigger. The
GitHub path stays stdlib-only, which is what makes that fallback trustworthy.
The second was the shutdown wrapper, which used to do its own authenticated
GitHub read. It could not have worked against Firebase -- an ID token lasts
about an hour and a shell script at shutdown time has no safe way to refresh
one. It now reads the local alarm cache the trigger already keeps fresh
(deployed to /usr/local/bin/shutdown), so the backend is invisible to it.
install.sh also now refuses to run under sudo, which silently skipped its
later steps.
Verified against the live database, not just in tests: the phone's alarm is
already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase
and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports
"07:45 (remote)" and the trigger dry-run behaves unchanged.
338 tests at 100% branch coverage, no new lint suppressions. The backend
tests moved to test_sync_backends.py to keep both files under the 500-line
cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
Every font here was a positive number handed to Tk, which reads that as
*points*: "WAKE UP!" at 48 rendered around a third larger than the 48px
intended, and the challenge code at 72 larger still. LockConfig.font()
applies Tk's sign convention, so the sizes now come from the design-system
roles (with an explicit scale where a challenge is deliberately oversized)
and the paddings from the spacing scale.
The alarm places a centred frame, which shears equal amounts off the top AND
the bottom when it outgrows the display -- on this screen that means losing
"WAKE UP!" and the answer entry at once, on a window whose whole purpose is
to be answered before it will stop. scripts/verify_screen_fits.py renders
every challenge kind plus the escape hatch against a real 1366x768 X server
and fails if any overflows; it runs as a pre-commit hook. Each kind is
measured separately rather than one at random -- the flash kind draws its
code half again as large as the rest, so a random sample would have passed
or failed by dice roll.
Fixed while sweeping: maybe_offer() reached for a `config` that was never in
its scope, so revealing the escape button raised NameError. Its own tests
caught it.
gatelock is pinned to v0.4.0, which stops the shared viewport scrolling
itself to follow the app's own focus_set() (2026-08-03, screen-locker).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
The escape-hatch form was a keyboard dead end. Tk traps Tab inside a tk.Text
(inserts a literal tab, refocuses the widget) and binds Shift-Tab to nothing,
so the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike
diet_guard's equivalent box, which at least binds <Return>, this one bound
nothing at all -- so a keyboard-only user who tabbed into the justification
field at 6am could not get back out to Submit.
Enter now activates Submit / Back / the escape button too. X11 Tk binds only
<space> on Button, so the key a user actually reaches for did nothing.
Escape returns to the alarm from the escape FORM. To be explicit, because this
app's whole point is resisting dismissal: this does not weaken the gating. The
alarm still requires DISMISS_ROUNDS_REQUIRED correct answers or the budgeted
escape hatch. The absent global <Escape>, the overrideredirect + grab_set_global
+ VT lock, and the hidden-until-earned escape button are deliberate adversarial
design and are left alone. What changed is that the *sanctioned* paths are now
reachable without a pointer.
Focus rings everywhere: the dismiss-code entry, the escape button, the
justification Text and the three escape-form fields all kept Tk's default 1px
BLACK ring, invisible against #211D1B. On a screen with no other window and a
global grab, that ring is the only feedback that a keystroke landed anywhere.
_alarm_view.py imported gatelock only under TYPE_CHECKING, so bind_activate
would have raised NameError at runtime while the module still imported cleanly
-- an import smoke test would have missed it.
phone_app is Android-only, so 1366x768 does not apply; its risk is landscape.
home_screen and settings_screen were bare Columns that cannot scroll, so
content overflowed with the stripe pattern painted over the primary control:
phone landscape leaves ~360px of body height for ~405px of content. Both now
scroll inside a SafeArea. The status line also rendered an unbounded raw
'Error: $e' with no maxLines, which added 100-150px and overflowed even in
portrait; it is capped at four lines.
The gatelock pin moves to v0.3.0, which is where the shared Tk-default
workarounds live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.2.0's recovery loop scheduled the next tick after running it, so any
exception out of a tick permanently ended all re-assertion of coverage, grab
and VT for the rest of the lock -- silently, since Tk logs and swallows
exceptions from `after` callbacks. 0.2.1 moves the reschedule into a
`finally`.
Also in 0.2.1: `holds_grab` no longer reads a grab held by one of our own
transient children (a posted menu) as "the grab was lost" and takes it back.
wake-alarm has no popup widgets on its lock surfaces, so that half is
insurance rather than a fix here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three pins, all of which have to move together or the gate lies: the
runtime dependency in pyproject.toml, the mirror of it in requirements.txt,
and -- easy to miss -- the copy inside the pylint hook's own isolated
environment in .pre-commit-config.yaml. With only the first two bumped,
pre-commit still resolved 0.1.1 and reported the v0.2.0 API as
no-name-in-module.
Picks up gatelock's warm-tinted unified-design-system palette + the new
on_fill token. wake_alarm's own UI wasn't audited for the on-fill
contrast bug this session (out of the 3-pilot scope that found it in
diet-guard/screen-locker/dufs-cloud) -- worth a follow-up pass.
Verified: wake_alarm pytest 300/300 @ 100% coverage against the new pin.
gatelock was folded into github.com/kuhyx/utils as a subdirectory;
update the pip git URL across requirements.txt, pyproject.toml, and
.pre-commit-config.yaml to the new location with the renamed tag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rewrites python_pkg.wake_alarm imports to wake_alarm, vendors the
shared configure_logging helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
untracks wake_state.json (runtime HMAC state, now gitignored). Scaffolds
standalone lint/test config copied from the already-corrected diet_guard
scaffold (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage), plus
the wave.Wave_write generated-members fix this package's _audio.py needs.