A release build gives no way to see the SharedPreferences cache that
AlarmReceiver reads at ring time (no run-as, no log of its contents), so
"the Dart UI shows it" could not prove "the alarm will stay silent".
- SickDays.store/dates log the sorted set under WakeAlarmSickDays.
- AlarmChannel getSickDays returns the cached set (null if unreadable).
- The Sick days screen reads it on open (before the sync rewrites it) and
after every sync: "Alarm cache: <dates>" / "none" / "unreadable".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later
day to mark it sick, tap again to clear; past days inert. Writes the
sickdays wire format (state sick/cleared + actor, one HLC per write) to
sickdays-sync/devices via crdt_sync syncLog.
- Mutual exclusion in both pickers: each syncs the other pool on open and
refuses a date it holds, naming the pool; an unreadable pool refuses all.
- Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the
ring with reason "sick_day" (no markFired, no ring_started); StickCheck
verdict sick_day, streak skips sick days; stakes card says so.
- Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request
code 1006) runs a headless engine (poolSyncMain) that pulls both pools
from RTDB and rewrites both native caches, so a PC mark arrives with the
app closed. The ring decision stays a cache read.
- JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision,
PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check.
pylint hook skipped: verify.py imports sickdays, not in the hook env until
the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
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
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
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
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
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
- 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
_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
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
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
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
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
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
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
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
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
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>
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>
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>
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
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
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