v0.2.0 drops freedays's gatelock dependency (importing gatelock pulled in
tkinter, so the headless pool needed a GUI toolkit) and adds the sync
timer that makes a day marked on the phone actually reach this machine.
Mechanical, but not optional: the freshness gate compares the pinned tag
against the newest release, so leaving v0.1.0 here would turn this repo
red on its next commit.
Suite re-run against v0.2.0 before pinning it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth app onto freedays (~/utils/freedays), the shared pool of
pre-declared days when every gate app stands down.
_skip_reason is the right seam: it already exists to say why the alarm
must not fire, and the free-day check joins the day gate and the
once-per-day latch as another clock-independent reason. It goes first
because it is the cheapest and the most absolute.
"free day" means the alarm does not ring -- not that it rings later. The
orchestrator is never started, so neither the alarm nor the workout lock
behind it appears. That is the one app in this fleet where standing down
is a physical event rather than an absent dialog, so it is worth being
explicit: nothing happens that morning.
Fail closed, as everywhere else: an unreadable pool reads as "not free"
and the alarm rings normally. Silence is never the failure mode -- there
is a test that a corrupt pool still wakes you up.
The pool is one local file read, so the per-minute tick stays a
millisecond-scale process with no network in it.
The pylint hook's gatelock pin had drifted all the way back to v0.4.1;
it moves to v0.7.3 alongside crdt-sync and freedays. pip refuses two
different direct URLs for one distribution, so that stale pin would have
made the hook environment unresolvable rather than merely old.
requirements.txt also takes pytest-randomly 5.0.0 and ruff 0.16.6, which
the freshness gate flagged once the file was touched. 171 tests, 100%
coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10/3.11 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.
What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and
ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes
`except A, B:`. That makes the source 3.14-only at the syntax level, which is
what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately
over holding ruff a release back (2026-08-29).
ruff, ruff-format, mypy --strict and pylint all clean at the new target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
Two CI failures, both real, neither visible locally.
**`python-kasa==0.10.2` requires Python >= 3.11** and the test matrix still
ran 3.10, so `pip install -r requirements.txt` could not resolve there. The
floor moves to 3.11 across `requires-python`, mypy and pylint, and the matrix
becomes 3.11/3.12/3.13. Nothing runs 3.10 -- production is system Python 3.14
-- so this documents what was already true rather than dropping support.
**The `pre-commit` workflow never checked out `kuhyx/utils`,** so both
delegating hooks exited 1 with "shared gate not found at
/home/runner/utils/...". That workflow has been red on every run since those
hooks were added; the freshness hook just made it two failures instead of one.
It now checks the shared repo out to `.utils` and points `UTILS_ROOT` at it,
the same way every other workflow in the fleet does.
465 tests at 100% branch coverage in a clean venv; gate exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
**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
CI caught what a developer machine cannot: the hook died with "No module
named" its own package. The package is pip-installed locally, so running the
script by path found it anyway; a fresh checkout has no install, and
`python3 scripts/x.py` puts only `scripts/` on sys.path.
`python3 -m scripts.verify_screen_fits` puts the repo root there instead,
which is the checkout in CI and locally alike. The xvfb re-exec runs the same
way, with an explicit cwd so the child does not depend on where the parent was
invoked from.
pylint had the same blind spot for the same reason: it infers sys.path from
the files it is handed, so a script outside the package resolved its imports
only when a package file happened to be in the same batch -- passing or
failing by what else was staged. An init-hook puts the repo root on the path
unconditionally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
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.
Adding the Flutter phone_app/ directory gave setuptools two top-level
candidates in a flat layout, so it refused to build:
error: Multiple top-level packages discovered in a flat-layout:
['phone_app', 'wake_alarm']
install.sh runs under `set -euo pipefail` and does the editable install
first, so every run since has aborted at step 1 — silently skipping the
systemd units, the sleep hook, the fan script and the shutdown wrapper.
That is why /usr/local/bin/shutdown was still the pre-token-fallback
version: not a forgotten reinstall, a broken one.
Declare the build backend explicitly and restrict discovery to
wake_alarm*, so phone_app is ignored. All 9 install steps now run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PC is no longer shut down at night, which broke the scheduling model
outright. The wake time used to be decided once, at hibernate, and baked
into the RTC by shutdown-wrapper.sh. With no shutdown there is no resume,
so sleep-hook.sh never ran and nothing fired the alarm at all.
Worse, wake-alarm.service was WantedBy=graphical-session.target and
enabled by install.sh, while _should_run_alarm() gates on day / dismissed
/ workout-logged but has no *time* gate. On an always-on machine that
target is reached on every login, so the alarm fired at login time on any
alarm day — which is every day.
Replace both with a systemd user timer ticking once a minute:
- _sync.py reads the alarm the phone pushed to kuhyx/syncs, mirroring
shutdown-wrapper.sh's jq parse so the two readers cannot drift. Caches
each read, then degrades fresh cache -> fetch -> stale cache ->
FALLBACK_ALARM_HOUR, so a network blip can never cost a wake-up.
stdlib-only: the units run /usr/bin/python, so a dependency here is a
production install-path hazard.
- _trigger.py fires morning-routine.service (never wake-alarm.service —
that skips the workout lock) when local time is inside
[alarm, alarm + TRIGGER_CATCHUP_MINUTES). The catch-up window covers a
suspended or just-booted machine; starting an already-active unit is a
no-op, so an alarm on screen needs no extra dedupe state.
- wake-alarm.service loses its [Install]; install.sh disables it.
A timer rather than a resident scheduler: each tick is a fresh process,
so there is no daemon to die silently and no wall-clock jump to handle
across suspend/resume. Ticks are per-minute for exact-minute firing, but
only hit the network when the cache is older than 5 minutes — ~288
GitHub reads/day, and phone changes land within ~5 minutes.
Degraded reads log at INFO, not WARNING: at one tick a minute a
persistent condition would be 1440 identical journal lines a day. The one
WARNING kept is _trigger firing on a non-phone alarm time.
Token resolution gains a fallback to the sibling apps' repo-scoped
sync_token, in both readers, so one PAT covers every app on kuhyx/syncs.
Note this surfaced that wake-alarm-sync/ does not exist in kuhyx/syncs at
all: the phone has never completed a push since the crdt_sync migration,
so the hibernate path had been silently using the +8h fallback. Fixing
that is a phone-side action (reconnect GitHub with repo scope, then one
"Set Alarm & Sync"). Alarms edited directly in the Clock app still do not
reach the sync — that needs an ACTION_NEXT_ALARM_CLOCK_CHANGED receiver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.