Commit Graph

9 Commits

Author SHA1 Message Date
6e668df5c1 Note that uninstall.sh is load-bearing for install
install.sh calls it as its last step, so renaming or deleting uninstall.sh
breaks installing, not just removing. Easy to miss from either file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:53:27 +02:00
3410b1bc45 Write down the two invariants this session cost
The sequential bootstrap in HomeScreen.initState and the published-age check
are both the kind of thing a later change reverts as tidy-up: one looks like
three independent futures that could run in parallel, the other like a
duplicate of the cache-age check. Both took a three-week silent outage to
find, so they are recorded with the failure rather than just the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:38:04 +02:00
e67cef0c54 Retire the PC's waking role; keep the trigger
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
2026-08-30 15:24:02 +02:00
10a594305c Bring the two files this change grew back under the 250-line gate
The gate runs --all-files at push time, so it caught two files this session
pushed over: AGENTS.md (244 -> 330) and home_screen.dart (238 -> 269). Both
are mine; the pre-existing Python overages are untouched, per the standing
rule not to split a file this change does not already have to edit.

- The phone section moves to DOCS-phone-alarm.md, following the repo's own
  pattern (DOCS-architecture.md, DOCS-vmbox.md), leaving a pointer.
- Alarm arming moves out of home_screen.dart into AlarmArming — "what it
  takes to arm an alarm" was never the picker's concern, and the extraction
  also repaired a doc comment my earlier edit had orphaned onto the wrong
  method.

flutter test: 69 pass. dart analyze lib/ test/: clean. APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:43:16 +02:00
dc2d54668b Phase 4/3: close the ring screen on dismiss, and make NFC registerable
Two defects that each defeated the alarm in a different way:

1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
   the alarm re-armed, but the dismiss screen stayed up over the lockscreen
   forever — back is disabled in both halves by design — and the live instance
   kept _inRoom=true with the solved answer still typed, so the next ring
   could reuse an already-passed gate. AlarmActivity now handles
   closeRingScreen; the shared AlarmChannel deliberately holds
   applicationContext and cannot finish an activity, hence the split handler.

2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
   checkNfc always answered "no tag registered" and checkQr was never called.
   Functionally that left steps as the only satisfiable proof, which is
   exactly the state the decision rules out ("NFC or QR qualifies; steps alone
   does NOT") and removes the redundancy whose purpose is that one failing
   mechanism at 06:00 must never block dismissal. Added RoomProofScreen
   (reader mode from MainActivity, since registration needs an Activity) and
   linked it from Settings.

Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.

New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.

Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.

flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:39:01 +02:00
af2a9b416b Phase 4/3: the phone's Dart half — ring screen, challenges, room proofs
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
2026-08-27 21:22:07 +02:00
97d32c803d Phase 6: vmbox becomes the gate for the system scripts (item 10)
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
2026-08-27 20:49:16 +02:00
b3e8c237af Phase 3b: finish item 14 -- delete the fan script, clean up installed state
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
2026-08-27 20:04:01 +02:00
4286fcb019 Phase 0: fix dependency pin mismatch, stale docs, add speaker research
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
2026-08-26 22:53:01 +02:00