Commit Graph

149 Commits

Author SHA1 Message Date
8696f2d0bf wake_alarm: sick days stand the PC down (fallback ring, RTC wake, trigger, workday penalty)
A date in the new shared sickdays pool (not a free day) now means, on the PC:
no fallback ring (a ring in progress stops, reason sick_day), no RTC
self-wake for that morning (ours is disarmed), no morning-routine trigger,
and no workday_penalty.json; a penalty issued on a day later marked sick is
retracted (penalty_cancelled, cause sick_day) by the next session refresh or
RTC tick. The audit treats the day as one where nothing is expected.

All pool access goes through wake_alarm/_sick.py: fail closed (unreadable
pool or missing package = not sick), and act modes pull the pool from
Firebase first (RTC --arm on a stick-day target, in-window fallback ticks
and ring polls, the day's first penalty write) because the sickdays timer
pauses through hibernate. Dry runs never pull.

Pins: sickdays-v0.1.0, freedays-v0.4.0, crdt-sync-v0.9.2 (needed by both);
pre-commit 4.7.0, pylint 4.1.3, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:45:29 +02:00
3b8cd21969 chore: add MIT LICENSE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 18:14:11 +02:00
ffe7666f49 deps: Flutter 3.47.7, shared_preferences 2.5.6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:27:56 +02:00
a0731c496c Sync: stop reading screen-locker's sync_token (screen-locker is Firebase-only now)
screen-locker's PC sync went Firebase-only on 2026-10-09 and nothing
maintains ~/.config/screen_locker/sync_token any more. The GitHub-fallback
token list now holds only diet_guard's repo-scoped PAT. Drop the stale
note about shutdown-wrapper.sh mirroring the list (the wrapper was removed
on 2026-08-30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:11:50 +02:00
57a6c6ba0e dependency freshness: bump pylint 4.1.2, url_launcher 6.3.3, github_device_auth v0.3.2 (CI red fix)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:39:28 +02:00
8e254dbef6 repo contract: CLAUDE.md commands, test_changed, CI gate, AGENTS.md update
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 16:58:12 +02:00
a6d5688eb7 CI: slim Flutter SDK for the interop job; APK only on app changes, Gradle cached
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 15:08:47 +02:00
83b9d5c4c7 CI: Flutter 3.47.6 in the workflow pins too
The .fvmrc moved in the previous commit; CI's freshness gate also reads the
flutter-version pins in python-tests.yml and release-apk.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 14:40:58 +02:00
9325103f5d Flutter 3.47.6; dismiss re-arm test no longer passes only on Tue-Thu
The expectation resolved the next alarm without the earned ramp set, while
the app (correctly) re-arms with the minute the dismiss just earned. On a
non-workday that is one minute apart, so the test failed Fri-Mon on any
Flutter version. It now loads the policy exactly as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 14:28:55 +02:00
f95cf826d2 gatelock 0.8.2 -> 0.9.0; mypy 2.4.0, pylint 4.1.1, ruff 0.16.10
gatelock 0.9.0 adds gatelock.morning_session (the shared reader of
wake-alarm's morning carrot, now used by screen-locker, leetcode-guard and
book-guard); nothing here changes behaviour. The tag moved the freshness
gate, so the pin moves with it, together with today's lint-tool releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 14:19:53 +02:00
012673aa99 TODO: v2 follow-ups for the next session
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 19:39:58 +02:00
9b3c3ba26b Docs: v2 verification status; vmbox can emulate RTC wake now
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:30:32 +02:00
4527834b45 Take pylint 4.1.1 (newest stable)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:27:37 +02:00
728e00cde7 Wake enforcement v2: PC fallback ring + RTC self-wake, stacking Tue-Thu phone lockdown, escape hatch until 2026-10-15, stakes in the app, daily incident audit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00
19f23f35a6 Free days: a month calendar of the shared pool; tap a future date to book it, tap a booked one to release it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:53:46 +02:00
7f7fd916f7 Earned ramp: a dismissed Mon/Fri/Sat/Sun morning earns the minute; workdays hold, misses and free days earn nothing; workdays from freedays 0.3.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:13:22 +02:00
a4876d84ec Speed up CI: uv installs, split pre-commit into mypy/pylint/rest
- pre-commit: drop the 28 s requirements.txt install (only
  dependency-freshness among the system hooks needs third-party code:
  pyyaml, packaging), run pre-commit directly instead of
  pre-commit/action, and split mypy and pylint into their own matrix
  jobs with per-group hook-env caches. Same hook set, same
  SKIP=free-days-interop (the rest group).
- Tests: uv with a cached wheel store for requirements.txt (pip took
  22 s), -q and -p no:sugar. No -n auto: the suite runs in ~0.5 s and
  measured ~1.2 s under pytest-xdist.
- free-days-interop (the critical path): build the freedays venv with
  uv instead of venv + pip (12 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
2026-09-26 17:45:00 +02:00
16f9ddeb13 Keep the test suite's JSONL log out of the live events.jsonl
_logging_setup attaches its JSONL sink the first time an entry point's
main() runs, and the conftest never redirected LOG_JSONL_FILE or
LOG_DEDUPE_FILE. Every suite run appended ~25 fixture warnings ("Synced
alarm out of range: 24:0") to ~/.local/state/wake_alarm/events.jsonl, the
log a failed wake-up is reconstructed from. Patch _logging_setup's names
(it imported them by value) in the autouse _hermetic fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
2026-09-26 17:44:52 +02:00
702f37a7ba Bump ruff 0.16.8 -> 0.16.9 and Gradle 9.7.1 -> 9.8.0: newest stable dependency-freshness gate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 13:27:37 +02:00
d8b07b2be4 Cook the frog: lower the wake baseline a minute a day from 08:00 to 07:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 13:15:44 +02:00
8be972c31a Record the two deferred hardware wake-defeat ideas as a repo TODO
ESP32 second-alarm device and wearable wake-defeat check were scoped
during the workday-stick session but explicitly deferred (no existing
firmware project, no owned wearable, both need a real purchase first).
Kept here instead of ~/src/todo/BACKLOG.md, which is gitignored and
export-only from the todo app and would silently drop the addition on
the next export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kwx6YYv9Mnw9EtRsJ6hbf
2026-09-23 22:07:56 +02:00
8275966a83 Bump pylint 4.0.8 -> 4.0.9: newest stable dependency-freshness gate
CI's dependency-freshness check runs live against PyPI and caught the
new release after this session's local pre-commit run used a stale
snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kwx6YYv9Mnw9EtRsJ6hbf
2026-09-23 21:37:38 +02:00
bfe8bab58a Add the Tue/Wed/Thu workday stick: signal focus-owner and write tomorrow's penalty
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:20:58 +02:00
92b7ae5d76 pre-push is a 1-second stamp check; the suites moved to the manual stage
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 21:29:10 +02:00
d953d67ae3 Open the PC from 05:00 until the alarm has been dismissed; the session is the check
The first carrot only opened at 08:30: someone up at 07:00 (the goal) got
the LeetCode gate at the 05:00 login-arm, and the workout lock was held off
by an unrelated clock. A day with no session yet is now exempt until the
phone's alarm plus 15 min -- clamped to the 08:00 baseline like the trigger
-- so the login-arm defers by itself; the alarm rings regardless, and a
dismiss that has produced no session by then means "not up". The refresher
runs from 05:00 (every 10 min until 07:00) so the file is today's when the
locks decide. The rule moved to _session_policy (250-line cap).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
2026-09-20 08:39:24 +02:00
c3c129b068 Make the morning session the carrot: earn 11:00 by finishing it in time
On 2026-09-19 the phone was powered off after the bathroom tag and nothing
on the PC noticed: every finished session was published and nothing read
it. Now wake-alarm distils the phone's morning into an HMAC-signed
~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer,
07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to
defer their locks -- provisionally until min(start + 90 min, 09:30) while
the session is live, until 11:00 once it completed inside that cutoff,
and not at all for a session finished later (the phone switched on at
noon completes the legs then; that must un-lock nothing).

The phone publishes the session at start as well as at finish
(current_session, cleared with the finish stamp), so the PC can tell
"started, not finished" from "never started". A required 1-5 energy tap
joins the weigh-in and ships as latest_energy with a PC reader mirroring
_weight (the shared half is _field_reader).

Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the
guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's
echo of the command line. Takes Flutter 3.47.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
2026-09-19 16:40:51 +02:00
af293413c0 Keep the morning session reachable after leaving the app; drop the still-phone watch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:01:22 +02:00
c7e7695466 Read the phone's newest weigh-in on the PC
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:49:08 +02:00
6bfb059309 Take gatelock 0.8.2, flutter_secure_storage 11.2.0, coverage 7.16.1 and ruff 0.16.8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:36:33 +02:00
f31f195187 Keep the morning going after the ring: weigh-in, shower, dress, desk, or it rings again
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:35:40 +02:00
84d6cbdc17 Take gatelock 0.8.1, Flutter 3.47.4 and flutter_secure_storage 11.1.1
The freshness gate's three findings. gatelock 0.8.1 restructured
LockConfig, which this repo does not use (only log_integrity), so the
pin moves without a code change; pylint stays at 10 against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:02:31 +02:00
07f2408f7e Re-point paths for the ~ reorganisation (home-tidy)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 18:46:34 +02:00
8e60a3667a Run the free-day interop gate in CI where Flutter exists
The pre-commit job has no Flutter and no freedays venv, so the new hook
could only fail there. It now runs as its own job in python-tests.yml
(utils checkout + a venv with crdt-sync and freedays + subosito Flutter),
and the pre-commit job SKIPs that one hook, with the reason next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 13:55:53 +02:00
758b2976b8 Gate the free-day rules against the Python package, and fix two picker paths
showDatePicker asserts that initialDate satisfies selectableDayPredicate,
so with today already free the picker asserted in debug and, in release,
pre-selected a disabled day. The cursor now starts on the first non-free
day. And the reason dialog had no Cancel: a barrier tap marked the day
anyway. Both covered by widget tests; the first fails without the fix.

The cross-language check that proved the Dart port was a one-off probe,
deleted after it ran. It is now scripts/free_days_interop/check.sh, a
pre-commit hook on both inputs: Dart writes one pool through every policy
branch, Python replays the same edits into a scratch pool, and every
semantic reading has to agree. The Python package is the oracle -- Dart's
own count compared to what Python reads from Dart's file cannot see a
drift in what Dart writes. Proved by dropping the sticky consumed flag
from release: remaining dart=33 python=32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 13:53:56 +02:00
146c427d6d Add the free-day picker, and stop the phone ringing on a free day
The shared pool (~/utils/freedays) was CLI-only: the gates on the PC stood
down, but the phone kept ringing at 08:00 and there was nowhere to mark a
day from bed. This is the one picker for every app.

FreeDayPool is a Dart port of the Python rules with the identical record
shape (id = ISO date; state/consumed/actor/reason on one tick; un-mark is
a state flip, never a tombstone), verified by a real cross-language round
trip: a log written here reads back through freedays.is_free_day and
remaining() with the same verdicts and count, and the PC's live pool reads
here as the same 27 of 35 that `freedays status` prints.

The alarm fires from Kotlin with no engine running, so the pool is
mirrored into a native SharedPreferences set after every mark, release and
sync (channel method setFreeDays), and AlarmReceiver consults it before
starting AlarmService. Fail closed: a missing or corrupt cache rings. All
three branches exercised on the API 36 AVD by broadcasting the receiver.

Firebase only for this namespace -- no GitHub mirror; the alarm record
keeps its own because un-migrated installs depend on it.

Known gap, documented rather than papered over: a day marked on the PC
reaches the phone at the next app launch or picker open, not overnight.
The ring path takes no background dependency by design, so mark on the
phone, or open the app once after marking on the PC.

Verified on the phone (release build, 23181JEGR08034): picker shows the
budget, marks 2026-09-30 with a reason, pushes it to
freedays-sync/devices/<uuid>, releases it with a full refund back to 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 13:45:42 +02:00
f709fb8028 Bring Flutter, flutter_secure_storage and ruff up to latest stable
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:37:33 +02:00
b5701fb359 Sync the NFC tags and home location so a reinstall gets them back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:34:50 +02:00
c0ed168540 Pin freedays v0.2.0
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>
2026-09-07 15:58:35 +02:00
2904d97b2c Bring the phone app's analyzer up to latest stable
very_good_analysis ^10.3.0 -> ^11.0.0, which the freshness gate flagged
against phone_app/pubspec.yaml the moment the pre-push CI mirror ran
--all-files. Pre-existing; it blocked the push rather than being caused
by it.

The major bump newly flags two async_return_with_no_await sites, both now
`return await` rather than suppressed. The rule is correct in both: a
future returned without awaiting escapes the enclosing async function, so
a failure surfaces at the caller with a stack trace that no longer names
the code that made the call. firebase_backend.dart:91 is the one that
matters -- a rejected credential fetch should fail inside the function
that describes itself as recovering from exactly that.

flutter analyze clean, 146 phone-app tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:30:00 +02:00
981396442d Do not ring at all on a shared free day
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>
2026-09-07 15:28:54 +02:00
4e5908baa7 Read the versionCode offset from the repo root, not the app dir
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:55:45 +02:00
7523cb4225 Ring the away profile when the phone is not at home
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:45:11 +02:00
25321d88fa Stop a hand-picked build number from stranding the phone
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:45:04 +02:00
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
6be5420fcf Stop trusting an alarm the phone published months ago
Belt-and-braces for the publishing race fixed in the previous commit. That bug
is gone, but the shape of the failure is worth closing on this side too: a
publish that never happens is indistinguishable from one that has not changed,
so the last successful publish stayed authoritative forever. One record stamped
2026-08-09 had the PC starting the morning routine at 07:00 for three weeks.

get_alarm now skips any record the phone published longer ago than
PUBLISHED_ALARM_MAX_AGE_SECONDS (14 days) and falls back. That is safe
precisely because FALLBACK_ALARM_HOUR is the phone's own baseline, so a lapsed
record degrades to the right answer rather than to a guess -- and an override
is one-shot anyway, so reverting to the baseline is what the phone would say.

The check is on when the PHONE stamped it, never on when we fetched, and that
distinction is the whole design. Checking the fetch would be trivially
defeated: the cache is rewritten from the same stale record every few minutes,
so `fetched_at` is always fresh no matter how old the record behind it is.
So the winning HLC's instant is now carried out of _alarm_payload, through
_sync_transport, and persisted in the cache as `published_at`. Nothing else
reads that cache any more -- the shutdown wrapper that used to went with the
PC's waking role -- so widening its shape is free.

An undated record is trusted rather than expired. The GitHub mirror reads a
single fixed path with no per-field HLC, so treating "cannot tell how old this
is" as "too old" would break that whole backend, and refusing an alarm we
merely failed to date is a worse failure than honouring it.

test_sync.py and test_sync_backends.py crossed the 250-line cap, so the new
lapse cases and the backend-ordering cases move to test_sync_lapsed.py and
test_sync_dispatch.py.

168 tests at 100% branch coverage. Verified against the real backend: the
cache now carries published_at, and `_sync --show` still resolves 08:00 from
the record the phone stamped minutes earlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:37:13 +02:00
60f79774c9 Fix the race that stopped the phone publishing at all
The PC has been firing the morning routine at 07:00 instead of 08:00, off a
record the phone published on 2026-08-09 and never superseded. The phone was
connected to Firebase the whole time and the home screen still said "Connect a
sync backend in Settings first."

initState fired three futures concurrently and the last one read what the
first two were still writing:

    unawaited(_applyPolicy());     // sets _hour/_minute
    unawaited(_loadSyncState());   // sets _service
    unawaited(_syncToPc());        // reads BOTH

`publish` takes the service as a *parameter*, so it captured `_service` by
value while it was still null. Assigning it moments later could never reach
the call. The startup publish therefore passed null every time and bailed out
before touching the network -- and `_syncToPc` had exactly one call site, that
one, so nothing published, ever. `_applyPolicy` has the same shape: a publish
that did win the race would have sent the baseline instead of an active
override. The bootstrap is now strictly sequential.

Three things conspired to hide it for three weeks:

- `publish` returned "Connect a sync backend in Settings first." for BOTH a
  missing backend and a null service, so a broken store looked like a setup
  step the user had never done. The advice pointed at something that was not
  broken. The two causes now report differently, with a regression test.
- `_loadSyncState` swallowed its exception with `on Exception catch (_)`, so
  if the store genuinely failed to open there was no trace anywhere. It logs
  now.
- `_pickEarlier` never published at all, so "Wake me earlier tomorrow" -- the
  one schedule edit the app offers -- never reached the PC either. It does now.

home_screen.dart went over the 250-line cap, so its body moves to
`screens/home_body.dart` as a stateless widget taking plain values. The screen
keeps the state and the ordering rules, which is where the bugs lived.

Verified on the Pixel 6a: home screen now reads "Synced to PC ✓", and the PC's
`_sync --show --no-cache` returns `08:00 (remote)` stamped seconds earlier,
against `07:00` from 2026-08-09 before. The phone had also minted a new device
id, so both records exist in Firebase and the HLC ranking picks the newer --
which is exactly why that reader ranks by HLC and not by listing order.

104 Dart tests pass; analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:28:20 +02:00
ada7e69c60 Don't let the retired-cleanup abort a working install
install.sh runs under `set -euo pipefail` and step 3 called uninstall.sh,
which uses `sudo rm`. On a machine without cached or passwordless sudo that
call fails, so the install aborted at step 3 -- after the package was
installed and the timer enabled. It would report failure for an install that
had actually worked, which is precisely the half-finished state the
sudo-refusal guard at the top of the script exists to prevent.

The vmbox harness could not have caught this: the guest's `arch` user has
passwordless sudo, which is why the run went green.

The cleanup is opportunistic -- a machine with no /usr/local/bin/shutdown
needs nothing done -- so it is now non-fatal, gated on `sudo -n` so it cannot
hang on a password prompt in a non-TTY context either. When it is skipped the
script says why it matters and gives the exact command, rather than leaving
the shutdown wrapper in place silently.

Verified: uninstall.sh --retired-only and install.sh are both idempotent and
exit 0 on a host where the retired pieces are already gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:38:30 +02:00
ccb0018a2c Take gatelock 0.7.3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:36:43 +02:00
a573b1b9d6 Fix two ways the vmbox harness went green while broken
Trimming the harness to the three surviving cases took `pacman -S tk` with it,
and that step was doing two jobs nobody had written down.

It was the guest's BOOT. `vm new`/`vm reset` leave a sandbox stopped and there
is no `vm up`, so the first `vm run` is what starts it; `vm repo` then failed
with "sandbox is not running". The pacman step is restored with that spelled
out, so the next person to prune it knows what else they are removing.

And tk is still genuinely required, despite the Tk alarm window being gone:
`_state` imports `gatelock.log_integrity` for the HMAC, and gatelock's
`__init__` imports tkinter at module scope, so `import gatelock` dies with
"libtk8.6.so: cannot open shared object file". Measured in the guest.

The second fix is the one that matters. `run_install` trusted `vm run`'s exit
code, but `vm run` reports the VM's verdict rather than the guest command's
status -- the same fail-open `guest_cases.sh` already guards against with its
ALL-CASES-PASSED sentinel. So install.sh died on that ImportError, the step
reported success, and the three cases downstream failed with no hint that the
install was the cause. It now confirms an INSTALL-OK marker in the output.

Harness passes: imports-from-system-python, trigger-timer-enabled,
trigger-dry-run. That run exercised the new install.sh end to end, including
its call into uninstall.sh --retired-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:33:35 +02:00