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
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
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
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