Dart side of the alarm (items 1, 4, 3). main.dart had no init seam at all;
it now has ensureInitialized(), a navigatorKey and a /ring route.
- challenge.dart: all five kinds ported from _challenges.py so both halves
behave identically. kDismissCodeLength/kDismissFlashSeconds are duplicated
from _constants.py, not synced — noted in the file, since Dart cannot read
Python.
- alarm_scheduler.dart: the Dart side of the single MethodChannel.
- ring_screen.dart: room proof first, then a challenge, then dismiss. Back is
a no-op (PopScope) so the alarm cannot be escaped.
- room_proof.dart: NFC or QR satisfies on its own; steps NEVER do alone —
the phone is stationary while you walk to it, so steps only accrue after
pickup and can be faked by shaking it in bed. Steps count only together
with elapsed time, as the fallback when both others fail.
- home_screen: arms the app's own alarm instead of asking the user to confirm
in the Clock app (item 1's "no user interaction"). SET_ALARM stays as
redundancy with SKIP_UI=true.
Re-arm has three hooks and needs all three: startup, BOOT_COMPLETED, and
dismiss. The plan assumed LogStore.changes would cover this; that stream does
not exist in crdt_sync_dart (verified — the library has no Stream at all), so
the picker's save is only one hook and a restart would otherwise arm nothing.
Fixed a real gap the API 36 AVD exposed: POST_NOTIFICATIONS was never
requested at runtime, so a fresh install sat at importance=NONE and the
full-screen intent was rejected outright — the alarm would ring with no
dismiss screen. MainActivity now requests it, and canUseFullScreenIntent()
also checks areNotificationsEnabled() so the check gates rather than warns.
Verified on the API 36 AVD: screen Asleep -> Awake, AlarmActivity became the
top resumed activity over the lockscreen, service isForeground=true
types=0x2 (mediaPlayback) category=alarm, no crash. flutter test: 54 pass.
dart analyze: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Hand-written Kotlin behind ONE MethodChannel, never a plugin: AGP 9.0.1 /
Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that will not compile
breaks the build outright rather than degrading.
- AlarmScheduler: setAlarmClock() (Doze-exempt, no SCHEDULE_EXACT_ALARM
prompt), persists the time so boot and dismiss can re-arm.
- AlarmReceiver: hands straight to the service — a receiver gets ~10s and no
wakelock, so nothing that must outlive it belongs there.
- AlarmService: foreground service, PARTIAL_WAKE_LOCK, USAGE_ALARM
(= STREAM_ALARM, which is what ignores DND and silent mode), looping
ringtone, vibration, full-screen intent over the lockscreen.
- AlarmActivity: showWhenLocked + turnScreenOn, routes to the Dart ring
screen, back is a no-op so the alarm cannot be escaped.
- BootReceiver: re-arms on BOOT_COMPLETED; AlarmManager drops alarms across a
reboot.
- StepCounter: TYPE_STEP_COUNTER baseline for item 3's fallback proof, also
hand-written to keep plugin risk off this path.
Details that are a hard crash or a silent no-ring rather than a warning:
FLAG_IMMUTABLE on every PendingIntent (mandatory since API 31);
foregroundServiceType declared on the <service> AND passed to
startForeground(); explicit android:exported on every component;
USE_FULL_SCREEN_INTENT declared (missing from the plan's list) and
canUseFullScreenIntent() exposed so the Dart side can gate rather than warn.
Every permission verified present in android-36's api-versions.xml rather
than from recall. Compiles green with `flutter build apk --debug`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
All three were delegated to the Flutter Gradle plugin, so there was no floor
to reason about — and full-screen-intent and foreground-service semantics both
changed at API 34/35, which is exactly the code the alarm path needs.
- compileSdk/targetSdk = 36, matching the target phone.
- minSdk = 28: the floor for android:showWhenLocked / android:turnScreenOn as
plain manifest attributes (no legacy branch), which also clears
flutter_secure_storage's API 23 and Flutter's own default of 24.
Verified with `flutter build apk --debug` (exit 0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Items 4/3/1 are their own session: new Kotlin on AGP 9 / Gradle 9 / Kotlin
2.3, an emulator that is not installed yet, and physical-device verification
at the end.
Records two prerequisites measured today rather than assumed: the app's
min/target/compileSdk are all still delegated to the Flutter plugin (no floor
to reason about full-screen-intent or foreground-service semantics), and only
/usr/bin/adb is on PATH — emulator, avdmanager and sdkmanager are absent, and
the image needs to be API 36 to match the phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Curtains move to Done. Remaining is the phone chunk, then gating, then the
black screen.
Two new traps, both paid for this session:
- Do not split a file the change does not already have to touch. The gate
fires on staged files, so editing _sync.py just to repoint an import pulled
its 371 lines in and cascaded into five modules and a broken tree. Put a new
constant in its single consumer instead.
- Extracting a __main__ CLI silently broke `python -m wake_alarm._sync
--show`: it printed nothing and exited 0, which every linter and the whole
suite passed straight over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
_curtains.py: a two-method driver protocol, a registry, and a logging no-op
default. prepare_hardware() calls open_curtains() next to turn_on_plug().
The no-op is not a placeholder awaiting hardware -- it is the correct
behaviour on a machine with no motor. Shipping it now means the call site and
the JSONL event are real, so buying a motor later is a config file rather than
a code change.
Shaped like _smart_plug.py, which solves the same problem: absent or malformed
config means "no curtains" rather than an error, an unknown driver name
degrades with a WARNING, and a driver that throws is caught -- a jammed motor
must never be the reason nobody is woken. The call sits on the startup path,
ahead of the first beep, so an exception escaping it would take the alarm down
entirely.
Why bother at all with no hardware: the audio failsafe silences the alarm at
FAILSAFE_SILENCE_SECONDS, and daylight is the one wake cue that outlives it --
and the only one independent of a sink, a speaker or an HDMI handshake, which
is the exact chain that made this alarm silent in the first place.
DOCS-curtains-hardware.md has the buy list, the three rules a new driver must
follow, and what is deliberately excluded (no cloud, no scheduling of its own,
no close-at-night -- closing belongs to the night lockdown, item 13).
CURTAINS_CONFIG_FILE and CURTAINS_DEFAULT_DRIVER live in _curtains.py rather
than _constants.py, since this is their only consumer -- the same call
_rounds.py and _alarm_session.py already make. That also keeps _constants.py
under the 250-line cap without dragging _sync.py's pre-existing overage into
this commit; splitting _sync.py belongs to the phase that legitimately touches
it (item 8).
465 tests pass, 100% branch coverage. vmbox harness 8/8, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Items 6, 2 and 10 move to Done. Remaining is the phone chunk (1/4/3), then
gating (8), then curtains (5) and the black screen (13).
Adds three traps this session paid for: vm run's exit code fails a gate open,
_state.py's UTC key is load-bearing across repos and must not be "fixed", and
ruff's autofix will silently gut a function whose only effect is printing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.
Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.
Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).
**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**
1. The inhibitor claimed a block it did not have. Popen succeeding only means
the binary launched; logind can still refuse -- the guest's polkit denies a
non-interactive session outright ("Failed to inhibit: Access denied", exit
1) while the alarm cheerfully logged the power key as blocked.
take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
the child survived, returning None and logging the refusal otherwise.
2. An unsigned session record was written and then rejected on read. With no
HMAC key on the machine (/etc/gatelock absent, as in the guest),
verify_entry_hmac returns False for a *missing* key exactly as it does for
a forgery -- so every session record was unreadable and item 2's power-off
defence silently did not exist. An entry with no "hmac" field is now
honoured with a loud warning; a present-but-wrong signature is still
rejected, since that one really is tampering.
Two harness traps worth recording, both hit while building it:
- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
harness greps for it; verified by breaking a case on purpose and watching
the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
into /home and fail with permissions errors that look like a guest fault.
Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.
Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.
Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.
455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Two defences, because one press and one held press are different problems.
**Prevention — _inhibit.py.** A logind block-mode inhibitor on
handle-power-key:shutdown:idle, taken while the alarm is audible and released
in on_close(). Confirmed on the host before writing it that
PowerKeyIgnoreInhibited=no is in force, so no logind.conf drop-in is needed --
and none is added: a drop-in changes the machine permanently, an inhibitor is
scoped to the alarm.
Verified live: the inhibitor appears in `systemd-inhibit --list` as
`wake-alarm ... shutdown:idle:handle-power-key ... block` and disappears on
release.
The child runs `sleep 7200`, never `sleep infinity`. An orphaned block-mode
inhibitor would hold the power button down until reboot -- worse than the bug
being fixed -- so it self-terminates, and a stale one is cleared at startup.
That cleanup was wrong on the first attempt and the host run caught it:
`pkill -f "--who=wake-alarm"` matched the *shell that had just typed the
pattern* and killed it, leaving the real inhibitor alive (`killed 0`, one
inhibitor still listed). Substring matching is inherently self-matching. Now
it lists `pgrep -x systemd-inhibit` and confirms each pid via
/proc/<pid>/cmdline. Re-verified: orphan reaped, exactly one live inhibitor,
zero remaining after release.
**Consequence — _alarm_session.py.** The real gap was that nothing on disk
ever said "ringing, not yet dismissed": wake_state.json is written only on
dismissal, so a hard power-off left no trace and the catch-up window closed
while the machine was down. A signed record is now written at fire time, and
_trigger re-fires on one still marked ringing, ignoring the window.
Deliberately no date field. _state.py keys by UTC and
screen_locker/_wake_state.py mirrors that helper to read the very same file
from another repo, so the UTC basis there is load-bearing. The plan's
_today_str() flip is therefore NOT done -- it would have made any dismissal
between 00:00-02:00 local read as yesterday's in screen-locker and silently
lose an earned workout skip. This record carries only an aware fired_at and
compares elapsed time, so the midnight question never arises.
Age-bounded at 5400 s: an alarm that dies before dismissal leaves a record
nothing ever clears, and unbounded this would restart the alarm every minute
forever.
Two bugs the unit tests could not see, both found by running it:
- A future-stamped record returned None, i.e. "not ringing". But a backward
clock jump is routine on a machine that hibernates nightly (NTP correcting
after resume), so that failed *open* on exactly the escape it closes: ring
on a fast clock, clock corrects, power-off works again. Now clamped to zero
within a 900 s tolerance and logged.
- _alarm.py derived the correlation id from the alarm's *start* instant. On a
catch-up or re-fire the PC would write wake-...-0735 while the phone wrote
wake-...-0700 and the two log streams stop joining. Now
correlation_id_for_today() derives it from the resolved alarm time, which is
what _correlation.py always specified.
Every trigger test patched should_refire, so _decide against a real record was
untested -- which is why only the manual run found the first one. There is now
an end-to-end test that writes a real session and asserts the decision.
Splits carried: test_trigger.py 294 -> 197 + test_trigger_main.py,
test_alarm_part2.py 266 -> 170 + test_alarm_timers_flash.py. The inhibitor
handle moved into _AlarmHardware, which also settles pylint's attribute count
and puts it beside the other thing on_close() gives back.
No new suppressions: the one inline pylint disable was redundant, since
consider-using-with is already disabled repo-wide in pyproject.toml.
452 tests pass, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
_reactivate.py shipped in phase 2a with tests but nothing called it, so the
HDMI sink fix was never actually in the alarm's path. WakeAlarm.__init__ now
starts the retry next to _activate_all_outputs(), stopped by the same
_stop_beep event the beep loop watches.
Verified on the host, not just in tests. Starting state was the exact bug:
alsa_card.pci-0000_01_00.1 Active Profile: off
sinks: alsa_output.pci-0000_0d_00.6.analog-stereo
After the retry ran, both passes logged:
2 sink(s) present: alsa_output.pci-0000_01_00.1.hdmi-stereo,
alsa_output.pci-0000_0d_00.6.analog-stereo
default=alsa_output.pci-0000_01_00.1.hdmi-stereo
and the captured card profiles restored cleanly afterwards.
Two wiring details worth keeping:
- _activate_outputs_only() drops what _activate_all_outputs() returns. Only
the first call sees the user's real audio state; every retry captures the
alarm's own. Returning one would let a later caller restore the alarm's
settings as if they were the user's.
- _list_real_sinks_now() re-resolves pactl each pass and reports no sinks
when it is absent. The retry outlives the startup lookup and runs on a
thread nobody is watching, where an exception would vanish silently.
_alarm.py was exactly at the 250-line cap, so the flash timers move to
_alarm_flash.py as a mixin, mirroring AlarmTimersMixin. AlarmTimersMixin's
abstract _start_flash_countdown stub had to go: it is first in the MRO and
would shadow the real implementation. main() stays in _alarm.py --
morning_routine/_orchestrator.py:39 hardcodes that path.
conftest stubs the reactivation autouse; without it every WakeAlarm test
would reshape the host's audio routing.
409 tests pass, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The PC shut down mid-session. This is the entry point for picking the work
back up without the transcript: what is done, what is left in what order, the
decisions already settled, and the traps that actually cost time.
Points at ~/.claude/plans/wake-alarm-still-does-witty-toucan.md for detail
rather than duplicating it, so the two cannot drift apart.
Every factual claim in it was re-checked against the tree, not written from
memory -- including the one that matters most: _reactivate.py is finished and
tested but is called by NOTHING (it appears only in a _startup.py docstring),
so item 6 is not actually fixed at runtime yet. That is step 1 for the next
session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Completes the fan removal started in 9e0cd12, which took the Python out but
left the shell side behind.
- Delete wake-alarm-fans.sh.
- install.sh step 7 now REMOVES the retired script instead of installing it.
An upgrade has to actively clean up: a machine that ran the old installer
keeps a passwordless-root sudoers entry for a script that no longer exists,
which is worse than the fan ramp ever was.
Verified on this machine rather than assumed -- both stale artifacts really
were present:
/usr/local/bin/wake-alarm-fans.sh (existed)
kuhy ALL=(root) NOPASSWD: /usr/local/bin/wake-alarm-fans.sh (granted)
After the cleanup: script gone, /run state gone, the fan NOPASSWD line gone,
`visudo -c` parses, `sudo -n true` still works, and the rtcwake NOPASSWD entry
the alarm actually needs is untouched. The sed was rehearsed against a fake
sudoers file first, since getting it wrong locks the user out of sudo.
- AGENTS.md: drop the stale "ramps fans to 100%" description, and point the
module map at the new DOCS-architecture.md (moved there to keep AGENTS.md
under the 250-line cap).
- DOCS-architecture.md: where each behaviour now lives after the split, the
HDMI-silence root cause, and the two timing invariants that must not
collide -- both of which silently gut the alarm if broken.
404 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Item 14: the fan ramp is gone entirely. It caused anxiety without helping
anyone wake up. Deletes _max_fans/_restore_fans/_find_fan_hwmon, the
_AlarmHardware.fan_state field, both call sites, and their tests.
wake-alarm-fans.sh and its NOPASSWD sudoers entry come out in the install.sh
pass -- dropping a passwordless root helper is a bonus.
_alarm.py was 478 lines and the 250-line gate fires on any commit that stages
it, so this carries the split it has owed for a while. New modules, each a
real seam rather than an arbitrary cut:
_gating should-it-run-today + CLI (no time gate; that is _trigger's)
_beep_loop the sounding thread, now driving _escalation's planner
_alarm_timers self-rescheduling challenge refresh / skip window (mixin)
_alarm_state the progress + hardware dataclasses
_rounds challenge presentation, re-exporting the view helpers
_countdown skip-window arithmetic and demo/production intervals
_dismiss_view the post-dismissal banner
_screen_flash the ~1.3 Hz attention flash (kept under the 3 Hz threshold)
_lock_setup gatelock config + arbiter claim
_startup wake display, brightness, plug
_tone tone synthesis and cache (out of _audio)
_brightness xrandr brightness (was in _audio, never belonged there)
main() deliberately stays in _alarm.py: morning_routine/_orchestrator.py:39
hardcodes "wake_alarm._alarm", and moving it is the exact mistake that caused
a month-long silent production failure once already. Verified after the split
that the module still resolves and accepts --production.
Also fixes the plug pre-roll found while researching speakers: main() fired
turn_on_plug() and the first beep in the same instant, so an amplifier's
soft-start relay swallowed the opening of the alarm. PLUG_PREROLL_SECONDS=4
now sits between them.
Two bugs found by running the code rather than by lint:
- ruff stripped `struct` from the extracted _tone.py because the import
landed before its first usage; only executing it caught the NameError.
- test fixtures patched _play_on_all_sinks/_set_max_brightness at
wake_alarm._alarm, which silently stopped intercepting once those moved.
404 tests, 100% branch coverage, no new noqa/type-ignore suppressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The failsafe silences AUDIO ONLY at 300 s. The lock, the typed challenge and
the workout gate all persist, so waiting it out in bed is not an escape --
it only stops a ring nobody is present to dismiss from sounding forever.
Adding it exposed two collisions that would each have quietly defeated the
alarm:
1. The escalation ladder ended outside the audible window. PHASE_SOFT_END
was 5 minutes and PHASE_MEDIUM_END 15, so with the failsafe at 5 minutes
the alarm was silenced before it ever left its SOFTEST rung -- the medium
and loud phases became unreachable code. Printed timeline before the fix:
0s soft 440Hz ... 299s soft 440Hz | 300s failsafe-silent
Phases are now 1 and 2 minutes, so the alarm spends 3 of its 5 audible
minutes on the loud alternating rung, which is what makes lying in bed
for five minutes unattractive.
2. ESCAPE_OFFER_AFTER_SECONDS was also 300, so the escape hatch appeared in
the same instant the sound stopped -- at its most tempting, precisely when
the alarm had just stopped being annoying. Moved to 900.
Both invariants are now locked in by tests, so retuning one constant without
the other fails instead of silently degrading the alarm.
Also fixes a latent test bug this surfaced: TestBeepLoopPhases anchored
elapsed time to "one minute past the previous boundary", so a phase retune
made it miss its rung, never set _stop_beep, and spin forever calling the
REAL audio stack -- hanging the whole suite rather than failing. It now uses
the midpoint of the band and patches every rung, so a mistuned boundary fails
fast.
test_alarm_part3.py split (372 -> 195) for the 250-line gate.
413 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Root cause of the silent-alarm-on-HDMI bug, measured on the host rather than
inferred:
$ pactl list cards | grep 'alsa_card.pci-0000_01_00.1' -A1
Active Profile: off
$ pactl list short sinks
61 alsa_output.pci-0000_0d_00.6.analog-stereo IDLE
While that card's profile is "off" the G27Q's HDMI sink does not exist, so
_list_real_sinks() cannot return it. _play_on_all_sinks re-enumerates sinks
every beep cycle -- its docstring says it picks up late outputs "without
re-running activation" -- but that can never find a sink whose card is off.
There is nothing to enumerate.
Activation runs exactly once, at startup. At wake time the monitor has not
finished its HDMI handshake, the profile reads "available: no",
_parse_profile_line drops it, and the card is never revisited. Silent alarm.
_reactivate re-runs activation on a decaying schedule (3,5,7,10,15,20,30s)
on a daemon thread, logging the full enumerated sink list every pass so the
next failure is diagnosable from the JSONL log instead of guessed at.
Verified end to end on this machine:
BEFORE: ['alsa_output.pci-0000_0d_00.6.analog-stereo']
AFTER : ['alsa_output.pci-0000_01_00.1.hdmi-stereo',
'alsa_output.pci-0000_0d_00.6.analog-stereo']
and a 1 kHz tone plays through the HDMI sink (paplay exit 0). Note the
constant ALARM_AUDIO_SINK was correct all along; only the activation was
failing.
Wiring into WakeAlarm lands with the _alarm.py split, since touching that
478-line file trips the 250-line gate.
399 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Adds the diagnostic substrate the rest of the hardening work builds on. A
6am failure should be reconstructable afterwards instead of re-debugged.
Two sinks, deliberately. stderr -> journal is unchanged. Alongside it, one
JSON object per line in ~/.local/state/wake_alarm/events.jsonl, so a morning
can be replayed with jq.
The hard constraint is that _trigger is a fresh process every 60 seconds, so
an in-memory rate limiter would reset on every tick and suppress nothing.
_log_dedupe therefore persists the last emitted fingerprint to disk and
flushes a suppressed_repeats count onto the next distinct record. Verified
across separate processes: 4 invocations logging the same routine event
produce 1 record plus an accurate count of 3.
That is what satisfies the standing "do not raise the level of _sync's
degraded-read paths" rule by construction, rather than by keeping levels
artificially low and losing the signal.
The correlation id is derived, never generated: both halves compute
wake-YYYYMMDD-HHMM from the resolved alarm instant, so the PC and the phone
agree with no handshake and no uuid exchange -- which matters precisely when
sync is down.
Logging never raises. An unwritable sink degrades to journal-only.
_alarm.py and _sync.py keep the default component= for now; tagging them
means splitting them for the 250-line gate, which belongs in the phases that
actually restructure those files.
389 tests pass, 100% branch coverage held.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Groundwork for the alarm-hardening work. No behaviour change.
- .pre-commit-config.yaml pinned gatelock-v0.4.0 while pyproject.toml and
requirements.txt pinned v0.4.1, so pylint type-checked against a different
gatelock than production runs. All three now agree on v0.4.1.
- AGENTS.md claimed ALARM_DAYS is "Mon, Fri, Sat, Sun"; _constants.py:9 is
frozenset({0..6}), i.e. every day. Corrected.
- Complete the CLAUDE.md -> AGENTS.md symlink migration that was left
uncommitted in the working tree.
- DOCS-speaker-recommendation.md: item 7 of the hardening work. Prose only.
Researched from official manuals/vendor FAQs. The deciding spec turns out
to be the power switch, not the LED: _alarm.py already cuts plug mains on
every exit path, so a speaker needs a latching mechanical switch to come
back without a button press. Anything shipping a remote has a standby
microcontroller that a mains cycle cannot clear (confirmed by Edifier's own
support article), which disqualifies it.
- Drop the stale "TODO: Specify your own unique Application ID" template
comment; applicationId has been set to com.kuhy.wake_alarm_sync all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The rename commit staged the git mv but not the accompanying content
edit, so these TODO files landed without the "REMOVE ME AFTER FINISH"
line the md-naming gate requires. Repos whose hook was already live
refused that commit; repos without it accepted a file the gate would
now reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.
TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
Copy of the canonical prompt in ~/utils/dopamine-ux/, distributed here so the
repo can be opened and the prompt run directly. Derived from a survey of this
repo plus "The Dopamine Architecture" (london-post.co.uk).
Each prompt states its own completion protocol: when finished, the session
deletes this copy in the implementation commit and logs a DONE row in
~/utils/dopamine-ux/00-INDEX.md. Partial completion is never recorded as done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv
The inline `_DeviceCodeDialog` (86 lines) was byte-for-byte identical to
diet-guard's, and `services/github_device_auth.dart` was a fourth copy of
the auth client. Both now come from `github_device_auth` in the
kuhyx/utils monorepo.
This app's client was not merely a drifted copy -- it carried a fix the
other three lacked: `pollForToken` swallowed a transient socket failure and
kept polling, because GitHub can close the connection at the exact moment
the user authorizes. Extracting todo's version would have silently deleted
that. The package took this behaviour instead, so the fix now protects all
four apps rather than just this one.
It is expressed as `on http.ClientException` rather than the local
`SocketException` arm, because the package is also imported by
diet-guard's **web** build where `dart:io` will not compile. Verified that
nothing is lost: package:http wraps a dead socket as
`_ClientSocketException`, which implements both types.
19 tests pass, analyze clean, and the release APK is installed on the
phone and launching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
Git-ignored files, vendored agent-skill bundles and the CI checkout dir are
now exempt, since no commit could ever fix them. Repo counts and the ranked
violation lists are regenerated accordingly; the global baseline drops from
657 to 597.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
A ready-to-use prompt describing the goal (every file at most 250 lines,
code and prose alike), this repo's actual violations ranked by Fowler ROI
(lines x commits/yr), how to split each kind, and the requirement to wire in
the shared pre-commit gate so files stay small permanently.
Generated from a measured survey of every repo; the gate itself lives in
~/utils (file_length/ + scripts/check_file_length.sh).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
Settings screen becomes a thin dispatcher with two links: "Sync settings"
(the shared sync_settings_ui package, Firebase-only) and "Advanced sync
(GitHub)" (app-local, moved verbatim -- the shared package carries no
GitHub surface).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
Same pattern as todo, diet_guard_app, and workout_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel dependence),
isFirebaseConfigured() reading the stored session rather than the account
marker alone, and the settings screen offering "Sign in with Google" ahead
of the password form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
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
run_number counts workflow RUNS, not code. Two failure modes, one of which
already bit: it resets to 1 if this file is renamed (the existing NOTE warned
about that), and it can fall BEHIND a build produced outside this workflow.
On 2026-08-09 diet-guard's phone held a manually sideloaded vc 8 while CI's
next build was vc 6, so `adb install -r` refused every CI build with
INSTALL_FAILED_VERSION_DOWNGRADE. The only non-destructive escape was
re-running the workflow until the counter passed 8 -- forcing a downgrade
needs an uninstall, which wipes app data.
`git rev-list --count HEAD` only ever increases, tracks the code rather than
the CI file, and is already far ahead of every installed build (diet-guard
62 vs 9, screen-locker 118 vs 5, wake-alarm 41 vs 5, todo 51).
Needs fetch-depth: 0 -- checkout@v4 is shallow by default and would count 1.
Verified the count is identical from the repo root and from app/, so the
job's `working-directory: app` default does not affect it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json,
and never parsed the HLC. The phone's device id is migrating from the fixed
constant "phone" to a per-install uuid (a role constant collides as soon as
a second install takes the same role, and a reinstall inherits the previous
install's CRDT identity). A reader pinned to the old path would have stopped
seeing the alarm the moment the phone migrated, fallen through to the cache
and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently.
So the PC half ships first, and this commit is that half plus the phone
change it makes safe:
- _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's
alarm.json, and picks the newest by the hour field's HLC rather than by
listing order. device_id breaks an exact tie so two devices stamped in the
same millisecond don't alternate between ticks; a device whose payload
doesn't parse is skipped rather than allowed to win with a bad value.
- SYNC_ALARM_PATH stays as a last-resort read for a backend that lists
nothing, so an un-migrated install keeps working. The GitHub mirror still
reads the single fixed path.
- The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key
todo and home_inventory already use) and pushes under it. deviceId is now
an instance field, injectable so tests don't touch real prefs.
Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's
500-line cap, and the wire format deserves testing without network or disk --
a misparse here doesn't raise, it just resolves to the wrong wake-up time.
Verified against the live RTDB, not just mocks: seeded a uuid-named device
directory and confirmed the reader discovered it, read it and ranked it --
losing to the phone with an old stamp, winning with a new one (03:15 vs the
real 07:45). Test device deleted; --show returns 07:45 and the cache is
intact.
354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
Spotted on the device right after installing the migrated app: the home
screen still gated syncing on `settings.isConfigured`, which is only true
when a **GitHub token** is stored. A phone connected to Firebase and nothing
else would be told "Configure GitHub in Settings first" and refuse to push --
and once the GitHub mirror is retired, that would be every phone.
Both the gate and the hint now accept either backend, matching what
syncBackend() already does: Firebase alone, GitHub alone, or the mirror that
writes both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
Completes the cutover started on the PC side: phone_app was still pinned to
crdt_sync_dart-v0.2.0, so the Flutter half was writing only to GitHub even
though _sync.py had already moved.
pushWith() now takes a RemoteStore instead of a GitHubClient, so one call
serves Firebase, GitHub, or the MirrorStore that writes both; syncBackend()
picks which, and returns the bare GitHub client when this device has not been
set up for Firebase -- the normal cutover state, and also the rollback. The
remote path is byte-identical on both backends, so only the transport differs
and the PC's parse is untouched.
Settings gained the Firebase section that actually calls saveAccount(). Its
absence was the bug found in diet-guard: without it openFirebase() always
returned null, so the app was a Firebase no-op on real hardware no matter
what the tests said. Signing in happens immediately on Connect, so a wrong
password surfaces on-screen rather than as a silent failure at push time.
The three UI problems kuhy hit in diet-guard are avoided here rather than
repeated: GitHub is demoted into "Advanced (GitHub mirror)" instead of
sitting beside Firebase as a peer choice, the connected account renders as
read-only text with a Disconnect button rather than an empty-looking form,
and the status card reports the Firebase state it is actually reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
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
wake-alarm had no APK build at all; releases were not produced. Adds a
standalone release-apk.yml plus the release signing config the app was
missing.
Signing follows the pattern proven in home_inventory: gradle reads
android/key.properties when present and falls back to the debug key when
absent, so local `flutter run --release` is unaffected. CI writes that
file from repository secrets and shreds it afterwards, and apksigner
fails the build closed if a debug-signed APK ever appears.
This repo has no Flutter analyze/test job, so the release job is not
gated on tests — a broken commit still publishes, with the build failing
as the only backstop. Add `needs:` if a Flutter CI job is added later.
versionCode is github.run_number, which is per-workflow-file — this file
must never be renamed, or the counter resets and updates stop installing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
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.
Per-output locking: the alarm now paints one full challenge per live
monitor instead of one window on the primary. `_ViewGroup` presents the N
copies as one -- every label update fans out, and because all entries share
a single root-mastered StringVar, answering on whichever screen you are
actually looking at dismisses the alarm everywhere.
The escape hatch is the reason this repo could not stay unmigrated. The
alarm was the only locker with no way out but four correct rounds against a
30-second refresh, which is a lot to ask of someone who has just woken up
and nothing at all to fall back on if the alarm misfires at the wrong hour.
It is deliberately exactly as hard as sick mode: hidden for the first five
minutes so it is never the easy first move, gone entirely once a rolling
budget is spent, and costing a written justification plus a wait that
doubles with each recent use. It grants no workout skip.
`EscapeHatch` owns the whole flow so open and close stay symmetric. That
symmetry is the fix for a real bug: `_open_escape` used to pack_forget the
challenge widgets and cancel only flipped a bool, so "Back to the alarm"
left a dead form on a blank screen, leaked the dialog frame, and made the
next open sweep the dialog instead of the challenge. pack_forget does not
remember its options, so the challenge now lives in one `challenge_frame`
that is forgotten and re-packed as a unit.
Tests: the single-output default made every "on every monitor" assertion
degenerate into 1 == 1, and a shared MagicMock return_value made N surfaces
N references to one widget -- a loop cropped to containers[:1] passed the
whole suite. A `dual_output` fixture plus distinct-identity mocks now cover
all five fan-out loops; each was mutation-checked by cropping it and
confirming the suite goes red. The suite also no longer reads the real
escape history or signs with the production HMAC key.
326 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
Replaces ~15 hardcoded hex/named-color literals with the shared
unified-design-system palette (bg/fg/muted/danger/warning/success),
matching diet-guard/screen-locker. Extracts widget construction into
_alarm_view.py to keep _alarm.py under the repo's 500-line cap.
pubspec.yaml's adaptive_icon_background is a separate literal from
python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated
every generated PNG but missed this one, so the actual on-device home
screen icon (Android's adaptive icon, not the legacy mipmap) stayed on
the old cool charcoal. Caught on the physical device, not by file checks.
testsAndMisc's generator now syncs this automatically going forward.
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.
Interactive walkthrough of the move from hibernate/rtcwake scheduling to the
per-minute trigger timer: why the old model broke when the PC stopped being
shut down, the login-fire hole that came with it, the fire window and its
catch-up, and the four-step alarm resolution chain.
Includes a simulator that runs the real _decide() rule, so the window and the
gate short-circuit can be poked at rather than just read about.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The doc claimed the migration pinned crdt_sync_dart-v0.3.0. It never did:
phone_app/pubspec.yaml has said v0.2.0 since the migration commit itself
(512fdd5), and pubspec.lock resolves it to 5be99ab. MIGRATION.md was written
afterwards (21aa3bd) and got the number wrong, so the doc drifted, not the
code — the build has always been v0.2.0.
A v0.3.0 tag does exist upstream. Say so explicitly, and say that not using it
is an untaken decision rather than a mismatch to reconcile, so the next reader
doesn't "fix" the pubspec to match a doc that was wrong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying an orange clock glyph, orange being this
app's own theme seedColor.
The clock is deliberately drawn without bell "ears" - at launcher size they
read as noise rather than as an alarm clock.
Adds the adaptive-icon layers the project never had, including an Android 13
monochrome layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).
shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.
Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ