LockConfig's colours moved into a nested LockPalette record; every
_COLORS.<colour> read follows. Nothing about the gate's behaviour changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9,
mcp 2.2.0 and ruff 0.16.7 so the freshness gate lets this through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
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>
Split out from the free-day change only by sequence -- diet-guard's
pre-push CI mirror runs the freshness gate over --all-files, so these
were already blocking a push before that work started.
permission_handler 13.0.1 -> 13.0.2 and workmanager 0.10.9 -> 0.10.10
are patch bumps. very_good_analysis ^10.3.0 -> ^11.0.0 is the major one,
and it newly flags 8 async_return_with_no_await sites across the desktop
wrapper and the sync/Firebase services.
Each is now `return await`, not a suppression. The rule is right in every
one of these cases: without the await the future escapes the enclosing
async function, so a rejection surfaces at the caller with a stack trace
that no longer points at the code that made the call.
background_check_service.dart:33 is the clearest example -- its own
comment says the push "is retried with backoff rather than silently
dropped", which is only true if the failure happens inside the function.
Nothing in CI or pre-commit runs `flutter analyze`, so this would not
have failed anything. Leaving a newly-red analyzer for the next person to
find is how a repo stops being analyzable at all.
flutter analyze clean, 706 Flutter tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third app onto freedays (~/utils/freedays), the shared pool of
pre-declared days when every gate app stands down. due_slots() returns
nothing on a free day, so no slot is ever due, the lock never opens and
nothing is said about the missing meals.
Read from one local file, never the network, so this cannot hang the
systemd-fired gate -- and an unreadable pool reads as "not free", which
means the normal rules apply rather than the gate quietly switching
itself off. Tested explicitly: fail closed, not open.
The conftest redirect is load-bearing. The pool lives under
~/.local/share, outside every diet_guard path the suite already isolates,
so without it a real free day on this machine would fail every "the gate
is due" test here for a reason that looks nothing like the cause.
gatelock moves v0.7.2 -> v0.7.3, and the pylint hook's own pins (which
had drifted further, to gatelock v0.7.1 and crdt-sync v0.6.0) come with
it. Not cosmetic: pip refuses two different direct URLs for one
distribution, so any stale pin alongside freedays -- which requires
v0.7.3 -- is an unresolvable environment rather than a warning.
requirements.txt also picks up pylint 4.0.8, pytest-randomly 5.0.0 and
ruff 0.16.6, which the freshness gate flagged once this file was touched.
1043 tests pass at 100% coverage across the pytest-randomly major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The freshness gate was already red on this before today's Python work touched
the repo -- it only surfaced now because the CI mirror runs the gate over the
whole tree, while the commit hook had no reason to look at requirements.txt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.
The fixes ruff then had to make are what the stale target was hiding: 18 files
on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), plus ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That second one makes the source 3.14-only at the syntax level, which is
exactly what `requires-python = ">=3.14"` already promised; kuhy chose it
deliberately over holding ruff a release back (2026-08-29).
ruff, ruff-format, mypy --strict and pylint all clean at the new target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
Python side: gatelock v0.7.1 -> v0.7.2, crdt-sync v0.9.0 -> v0.9.1 (both
re-cut so consumers stop resolving pre-upgrade constraints) and every `>=`
floor turned into an exact pin, five majors among them (mypy 1 -> 2.3.1,
pylint 3 -> 4.0.7, pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, ruff 0.8 ->
0.16.5).
app/: twenty pins, of which permission_handler 12.0.3 -> 13.0.1 and
workmanager 0.9.0+3 -> 0.10.9 are real major moves the lockfile had been
holding back, plus the flutter_secure_storage 11 chain (compileSdk 37 /
AGP 9.3.2 / Gradle 9.7.1). CI's Flutter pin was 3.44.8 against an .fvmrc of
3.47.2; both say 3.47.2 now, and the gate reads that pin.
`scripts/ci_mirror.sh` green end to end: the Python suite in a clean venv and
706 Flutter tests, pre-commit --all-files clean, gate exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
`any` was the only unconstrained Dart dependency in the whole fleet, and it is
the one constraint a freshness check cannot compare against anything: it is
satisfied by every version that has ever been published, including versions
that do not exist yet.
Resolves to the same 2.1.8 the lockfile already had -- pubspec.lock is
byte-identical and all 706 tests pass -- so this is a declaration change, not a
dependency change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
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.
Includes the patch bump this repo's bump-patch-version hook applies to
app/pubspec.yaml on every commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
`fillControllersFromDish` set the dish's name, four macros and `grams` but
left `perGrams` untouched. A `100` left there by an earlier food-bank pick
made `nutritionForPortion` read the dish's per-portion macros as per-100g and
rescale them: one real entry on 2026-08-23 logged 472 kcal as 1217.8 (258/100).
The PC never had this -- `_gatelock_delivery.py` calls `_clear_inputs()` first
-- so the fix is that same discipline, as one `MacroControllers.clear()` call
rather than a `perGrams.clear()` line a seventh field could forget. `fillFrom`
now clears too, and `edit_entry_screen` routes through it instead of six
hand-written assignments; its `initState` fill had the same shape.
The old tests asserted controller *text* and passed throughout the bug, so the
new ones assert the computed `Nutrition` -- verified failing (400 -> 1200.0)
before the fix. `clear()` gets an enumerated test so a new field fails until
it is cleared too.
Also adds the log screen's "Today's delivery" button, matching the PC lock
screen's affordance: explicit taps are unguarded per the Triggers table, still
skip dishes already logged, and show the queue count. The whole action row
moved to its own widget, which put `log_meal_screen.dart` back under the cap.
The label is a FittedBox in an Expanded -- measured, since a Row clips an
oversized label without ever throwing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Both pass the entire suite, which is why they shipped:
1. A prefilled dish logs ~2.6x its calories. `fillControllersFromDish` never
clears `perGrams`, so a stale `100` makes `nutritionForPortion` treat the
dish's per-portion macros as per-100g and rescale by grams/100. Observed
on the user's phone: a 472 kcal / 258 g dish logged as 1217.8 kcal, and
1217.8/472 is exactly 258/100. The PC avoids it by calling `_clear_inputs()`
first; the Dart mirror dropped that step. The existing widget test asserts
controller *text*, which is correct while the computed nutrition is wrong.
2. No one-tap delivery button on the phone's log screen. The queue works, but
only `initState` and the Settings button reach it, so there is nothing to
tap while standing in the kitchen.
The handoff carries the reproduction, the confirmed root cause and the file
and line, so the next session does not re-derive them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Documents the Dart mirror, the phone's two triggers (guarded log screen,
unguarded settings button — the same split as the gate vs the CLI), and the
device-local fetch marker on both sides.
The three parity traps are written where the next reader will hit them,
because each survived review once: `_ENERGY_TOLERANCE` is 0.35 and the "~1%"
elsewhere describes the captured day, not the threshold; `jsonEncode(435)`
emits `435` where Python emits `435.0`; and Dart's `List.sort` is unstable
where Python's `sorted` is stable.
Also states plainly what the shared fixture does *not* prove: that the two
implementations agree on a fixed payload, not that they agree against the live
panel.
**That second claim is now observed rather than inferred.** On 2026-08-23,
with the credential synced to the phone and nothing typed on it, the phone
logged into the live panel and banked 5 dishes. Pulling that back to the PC
left all five curated-bank records carrying their original 08:00:03 PC
timestamps, unchanged — the phone's records were byte-identical, so its
idempotency guard wrote nothing. Any disagreement would instead have re-added
them with fresh stamps and started the republish flood.
Split into a second file for the 250-line cap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
`KuchniaQueueService` had no `init()` call in `main()`, so on a real device
the whole feature would have done nothing: every entry point checks
`isInitialized` first and returns quietly, which reads as "no delivery today"
rather than as an error. All 694 tests passed throughout, because widget tests
initialise the service directly.
Caught by grepping main.dart before the phone deploy, not by the suite. So it
is now a repo invariant: `repo_invariants_test.dart` asserts main() names both
catering `init()` calls, and the assertion was checked against a main.dart
with the line deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone now prefills the log form with today's delivered dishes, in the
caterer's own meal order. It still never logs anything itself -- a delivered
meal is not an eaten meal, so the user taps for each dish.
Two guards carried over from the PC, both of which fail silently if dropped:
- **The queue survives a submit.** `prefillNextDish` has two callers: the
initial load and the post-submit advance. On the PC the second briefly did
not exist, which made "(N more to go)" a dead letter -- every dish after the
first waited behind another tap. The test asserts the *form controller*
holds dish 2 after dish 1 is logged, not that a dish is "available"; the
latter passes throughout that bug.
- **The automatic refresh is guarded** by a device-local last-fetched date,
mirroring `refresh_delivery_once`. The log-flow trigger can fire after every
meal and each unguarded walk is a login plus three requests. The settings
button deliberately stays unguarded: an explicit ask always goes and looks,
matching `diet-guard kuchnia`. Only a clean fetch records the day, so an
outage retries.
Dishes are ordered against `MealScheduleService.current`, never a hardcoded
four slots -- offering a dish for a slot the user's schedule does not have is
the "checkpoint that can never be satisfied" failure in a new costume.
Editing the description before submitting leaves the dish queued: what was
logged was not the delivered dish, and marking it eaten would lose a real
delivery silently.
Two bugs the tests caught:
- `dishesStillQueued` excluded the offered dish, so the status line read
"0 more" while a real dish sat in the form. The offered dish is prefilled,
not logged; it counts.
- An end-to-end test through the real `LogMealScreen` never terminated --
that screen's `initState` fans out into sync, notifications and the food
bank, and pumping it to quiescence hangs. Rather than keep fighting it, the
behaviour is tested at the mixin that owns it, through a minimal host. The
guarantee asserted is the same one: form contents after a submit.
`log_meal_screen.dart` is back under the 250-line cap by moving the nutrition
build into `log_meal_actions.dart` and inlining a one-line alias.
694 Dart tests, 1040 Python tests, 100% Python branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The credential **fields** appear on every platform; the **fetch button** only
where it can work. That split is the point, not an oversight: the panel sends
no CORS headers so a browser cannot call it, but the desktop app is where a
password is most likely to be typed, and the credential syncs — so hiding the
fields on web would mean the phone could only ever be set up by typing on the
phone. On web the button is replaced by a line saying why, and noting the
login still reaches the phone from there.
The section says the password is stored and synced in plain text rather than
implying otherwise.
The button disables itself while a fetch is in flight, so an impatient
double-tap cannot pay a second login plus three requests against a third
party. The result line says dishes were added to the **food bank** and that
the user still logs them when eaten — the wording carries the
delivered-is-not-eaten rule to the one place a user actually reads.
`settings_screen.dart` was already at 247 of its 250-line budget, so the
section owns its own leading divider and the fetch handler lives beside it;
the screen spends one line on it.
678 Dart tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone now walks the panel on its own -- login, active order, delivery,
menu -- parses the dishes and banks them, so the feature works with the PC
switched off. Read-only: nothing here logs a meal.
`package:http` has no cookie jar, so the `SESSION` cookie is read off the login
response and replayed by hand. `Response.headers` folds duplicate `set-cookie`
values into one comma-joined string, so the parser splits on both `,` and `;`
and looks for the named cookie rather than assuming the header holds only it --
tested against both orderings and against a panel that sets no session cookie
at all.
Android-only, as decided: the panel sends no CORS headers, so a browser cannot
call it. `refreshDelivery` checks `kIsWeb` first and returns the unsupported
reason without touching the network. No `dart:io` anywhere -- `package:http`
compiles for web, it is the panel that refuses, not the toolchain, so this
needs no `_io`/`_web` seam.
The idempotency guard is ported with its teeth: `addManualEntry` restamps
`editedAt` unconditionally, so re-banking an unchanged dish republishes the
whole curated bank to every peer. `bankDishes` compares the nutritional fields
first and returns a write *count*, which is what the tests assert on -- "the
entry exists" passes while this misbehaves. The comparison is numeric, not
string, so `270 == 270.0` is a match exactly as in Python's `_matches`.
Every failure becomes a reason string, never a throw, so a catering outage
cannot break the caller: a rejected login, a delivery whose dishes all fail
the unit check, and missing credentials each have a test.
One test bug worth noting: the assertion that nothing is logged was missing an
`await` and so was comparing a Future against `isEmpty` -- the single most
important guarantee in this file was passing vacuously until the split forced
it to run.
Test files split three ways for the 250-line cap; block counts checked against
the pre-split file (16 either side), not just a green suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The Dart half of the credential sync: a `KuchniaCredentialService` that owns
the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python
one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The
phone can now receive the panel password, which is the prerequisite for it
fetching anything itself.
One source of truth, per the answered question: the value lives in the synced
document store and nowhere else. Deliberately *not* mirrored into
`flutter_secure_storage` -- two homes for one value is how a stale password
survives a change made on the other device, and the credential has to be
readable synchronously at merge time anyway.
Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`,
asserted by both suites. Two cases carry the weight:
- an **unparsable** edit time must fall back to the same clock on both sides
(Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns
null). Verified they agree on `wall_time_ms == 0`; mutating that expectation
fails both suites.
- a **blank half** contributes nothing, so a half-filled settings form cannot
push an empty-string password that wins LWW against a real one.
`KuchniaCredentialService.init()` is called in the WorkManager isolate as well
as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential`
no-ops when the singleton is missing, so without it a background tick would
neither publish this device's credential nor receive a peer's -- and on the
phone the background tick is most of the syncing there is. Same class of trap
as the `initSyncDeviceId()` one already documented there.
`.toDouble()` came off the bank record: the fields are already `double`, so it
was a no-op the analyzer flags. The field *types* are what keep the encoding
byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that.
654 Dart tests, 1040 Python tests, 100% Python branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
`_local_credential_log` fell back to the hand-written `kuchnia_credentials`
and used its **mtime** as the edit clock. That clock is not trustworthy:
`git checkout`, a backup restore, and re-running the `install -m 600` setup
line all bump it forward without the credential changing.
Reproduced before fixing: a PC whose file had merely been touched, syncing
against a phone that had set a new password an hour earlier, resolved to the
PC's stale password. The phone user would have seen their edit silently revert.
The bootstrap is still needed -- someone has to publish first, and the PC is
where the password is typed -- so it moves to `_bootstrap_credential_log` and
runs only when the merge came back completely empty. It can no longer outrank
a real edit from a peer; it can only fill a vacuum. Once anything has been
published, the synced cache (which carries a real edit time) takes over
permanently.
`test_a_touched_handwritten_file_cannot_clobber_a_peer` pins it, and was
checked against the pre-fix code: reintroducing the fallback fails it.
Also rewrites the two remaining places that still claimed a credential "must
never leave the machine it was entered on" -- `_constants.py`'s comment and
the doc's Credentials section, which now has a table of which of the three
files syncs and which does not. Rewritten rather than given an exception
clause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone is getting its own Kuchnia importer, and it cannot fetch anything
until the panel password reaches it. So the credential syncs, and a phone that
has been wiped and reinstalled gets it back without the user digging out the
original.
**It travels in plaintext.** Nothing here encrypts it and the rest of the
synced state is not encrypted either, so the docs no longer say a credential
"must never leave the machine it was entered on" -- that paragraph is rewritten
rather than given an exception clause. What still never leaves is the session
cookie: it is regenerable from the password, so syncing it would widen exposure
and buy nothing.
Its own document, not a field on `budget`. `budget.json` is written back at
default permissions, so a password riding inside it would be readable by
anything that reads the budget. Two independently-clocked fields, so a device
that only ever set the username cannot blank a peer's password.
The hand-written `kuchnia_credentials` keeps its contract -- written by the
user, never by this package -- and now acts as a local override that wins over
the synced copy, plus the bootstrap for the very first push. Without that
fallback no device would ever publish, since the synced cache starts empty on
every device.
Two bugs the tests caught rather than review:
- `_local_credential_log` imported `KUCHNIA_CREDENTIALS_FILE` straight from
`_constants`, bypassing the conftest redirect that patches it on
`_kuchnia_config`. The suite was reading the real ~/.config credentials. It
now goes through the module attribute, which is what the redirect replaces.
This showed up only as a coverage hole -- the branch was unreachable because
the real file existed.
- `log_to_credential` guarded `password_hlc is None`, which cannot happen: a
field's value and clock arrive as one tuple. Dead code, removed rather than
covered by a contrived test.
The relay canary is a real test now, not a scratch script: a field from a
future release survives merge and re-push untouched, so this is shippable
without a coordinated release. `PASSWORD_FIELD_NAME` is spelled as a join
because ruff's S105 reads the literal as a hardcoded secret and the repo bans
`noqa` outright; the wire name is still "password".
100% branch coverage, 1036 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone is getting its own Kuchnia Wikinga importer so the feature works
with the PC switched off. That turns a risk `_kuchnia_spread.py` called
"theoretical" into a live one: two implementations must now agree exactly on
which dishes are dropped, which slot each lands on, and the JSON a banked
record encodes to. Disagreement means each device re-adds what the other
dropped, and since `add_manual_entry` restamps `t` unconditionally, the whole
curated bank republishes to every peer on every refresh. A slot mismatch is
worse -- a checkpoint one device offers and the other does not can never be
satisfied.
So the port lands with its gate rather than after it. One shared fixture,
`tests/fixtures/kuchnia_day.json`, is asserted by both `pytest` and
`flutter test`: same input, same expected dishes, drops, slots, bank keys and
records. Two suites written independently from the same prose is not a gate.
The fixture is five real dishes (a scrubbed 2026-08-22 capture) plus synthetic
meals that each pin one drop or fallback path -- a per-100 g mix-up, an absurd
portion, a stringly-typed number, a non-dict entry, a missing `nutrition`, a
missing `mealPriority`, and a twin-dish pair. Without those, "the same meals
are dropped" is a vacuous assertion against an all-happy-path capture. Both
halves were mutation-checked: flipping an expected slot, drop count or record
value fails each suite, and flipping `435.0` to `435` fails the byte-identity
test while the value comparison still passes, which is why both exist.
Three traps the port had to survive, each caught before it shipped:
- `_ENERGY_TOLERANCE` is **0.35**. The prose said "~1%", which describes how
closely the captured day agreed, not the threshold; porting 0.01 would drop
dishes the PC keeps. That paragraph now says so.
- `jsonEncode(435)` emits `435` where Python emits `435.0`, so every Dart
macro is forced to `double` and a canonical-encoding test pins it.
- Dart's `List.sort` is unstable where Python's `sorted` is stable, so the
Dart comparator carries the payload index as a final tiebreak.
The real dishes live in their own file rather than as a literal in the
generator: transcribing them by hand corrupted three of the five, and the
error was invisible until the generated fixture was diffed against the
capture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Session hit the 400-turn cost gate mid-task. Records the four decisions the
user already made (phone fetches directly, password syncs plaintext, session
cookie stays local, rewrite the contradicting doc rule), the prerequisite
ordering the settings field imposes, and the Python/Dart parity risk this port
converts from theoretical to real.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
`adb install -r` preserves app data and phone_deploy already refuses every
path that would not, but "preserved" is a property of the happy path -- a
failed install or a later wipe leaves unpublished entries existing nowhere
else. The log is CRDT-synced precisely so a recoverable copy exists, so the
deploy now checks that one does before touching the device.
The obvious device-side signal does not work and would be a gate that can
never pass: the app records a successful tick through SyncHealth, which writes
to SharedPreferences (app-private, unreadable over ADB on a release build),
and it only writes to logcat when a sync *fails*. So the check is remote-side
-- pull the shared log and assert it holds recent entries, which answers "is
there a copy off the device" directly.
Advisory by default; SYNC_GATE_STRICT=1 makes it block (exit 38). A network
outage must not stop a fix reaching the phone.
The shell half lives in ~/.claude/scripts/lib/phone_deploy_sync.sh. Verified
against the real phone: passes when a peer is current, skips cleanly for an
unsynced or not-yet-installed app, and blocks with exit 38 in strict mode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Walking a real 5-dish delivery through the gate by hand showed the
"(N more to go)" the lock promises was a dead letter: `_prefill_next_dish`
had exactly one caller (the initial poll), so logging the first dish cleared
the form and stranded the rest. Reaching them meant clicking "Today's
delivery" again -- and the button's fetch is unguarded, so that was a fresh
login-plus-three-request walk per dish.
`_finish_slot` now prefills the next queued dish, carrying the
"Logged HH:00 ..." confirmation into the same status line so advancing the
queue does not swallow it.
The caterer plans five meals against four default slots, so a dish is still
queued when the last slot unlocks. It is already banked, so `_unlock` names
it rather than dropping it silently.
Also pins the phone conclusion that was previously reasoning only: a verbatim
capture of a real imported bank now round-trips through `FoodBankRecord` in
`app/test/kuchnia_bank_interop_test.dart`, covering the diacritics and the
`t` stamp, and the casefold/toLowerCase agreement the two devices depend on.
Verified against the live panel with state redirected to a scratch HOME
(real ~/.local/share/diet_guard byte-identical before and after): the
autoload prefills silently on window open, a failed autoload stays silent
while the button reports, the after-log warm-up runs off-thread (0.62s vs
0.28s guarded) and nothing is logged until "Log & Continue".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The CLI is verified against the live panel, but the gate button, the
window-open autoload and the after-log warm-up have only ever been exercised by
tests. Records what to check on each, plus the four invariants that must not be
undone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.
A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.
Three surfaces:
- `diet-guard kuchnia [--log] [--yes]`
- the lock screen's "Today's delivery" button, which prefills the meal form
dish by dish (the user still clicks Log & Continue)
- a warm-up after each logged meal, on the existing background thread
All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.
The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.
Load-bearing details:
* Macros are per portion and `weight` is that portion in grams -- proven, not
assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
the captured day. `_energy_is_consistent` re-runs that check per dish and
drops any that fails; a per-100g mix-up is invisible in a payload and would
silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
which restamps `t` unconditionally. Without the compare, every refresh
republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
`log_meal` mints a fresh uuid, so a re-import after marker loss would merge
as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
`gate_is_due()` never reads the food bank, so a refresh there could not
change the lock decision while adding a third-party round trip to the ~105ms
`gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
mode 600, outside the synced tree. The credentials file is written by hand,
never by the package -- the same contract as sync_token.
`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.
Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.
989 tests, 100% branch coverage, pre-commit clean.
diet-guard does not call sync_log directly, so v0.9.0's bundled signature is
a no-op here; re-pinned to keep every consumer on one tag. 830 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
v0.8.0's tag was already pushed when the timeout-forwarding tests landed, so
the covering tag is v0.8.1. No library behaviour change over v0.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
v0.8.0 pools HTTP connections in the shared library, which is worth ~95ms per
GitHub request and ~52ms per Realtime Database request on this machine.
This jumps two tags, not one: the pin was still v0.6.0, so it also picks up
v0.7.0's "let a mirror read survive the primary being down". Full suite
re-run after the bump rather than assuming only the pooling change landed --
830 passing at 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
The remaining item from the sync-latency work is cross-repo (library at
~/utils/crdt-sync, tag bump, re-pin here), so it belongs in its own session
rather than at the tail of a 600-turn one.
Carries the measurements a fresh session would otherwise have to rediscover:
82ms/req saved on GitHub and 51ms on Firebase, against a tick that now makes
27 requests in 7.6s, so ~1.7s (~22%) is the target to hold the work to.
The load-bearing constraint is written down explicitly: 16 tests across three
files patch the *module attribute* (`patch.object(fb.requests, "get", ...)`),
so an implementation that forces a mass test rewrite is the wrong one. Also
corrects a claim I made earlier in the session -- only diet-guard is on 0.6.0;
screen-locker is already on 0.7.0 and the other three are on 0.5.1, and none of
them should be touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
The previous commit turned tracked `CLAUDE.md` into a symlink to an *untracked*
`AGENTS.md`, deleting 237 lines of documentation from the repo -- the content
survived only in my working tree, and a fresh clone would have got a dangling
symlink. The symlink predates this session (it was already a type-change in the
working tree at the start); committing it without tracking its target is what
broke it.
Tracking `AGENTS.md` then tripped the 250-line cap, so the sync-latency
material moves to `docs/sync-latency.md` behind a pointer -- the same shape the
repo already uses for `docs/meal-schedule.md` and the Flutter rules. That file
carries the measurements, the three narrow-pull state invariants, the Tk
threading rules, why the gate's publish must stay synchronous under
`Type=oneshot`, the timeout asymmetry, and the `revs/` marker trap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
CLAUDE.md still described the gate as pulling a full tick before a lock, which
is exactly what changed. Documents the narrow vs full split, why the gate's
publish must stay synchronous while the CLI's must not (`Type=oneshot` reaps
the unit), the interactive timeout and why `SYNC_TIMEOUT_SECONDS` never
covered Firebase, and `prune-peers` -- including the revs-marker trap, since
that one made things measurably worse before it was caught.
Also extends the test-isolation note: the seam now has four patch targets, and
a new network entry point missing from it reaches the real remote from the
suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
Pruning 20 dead devices made the narrow pull *slower* -- ~90ms to ~15s --
which is the opposite of the point, and I caught it only because I measured
after running rather than assuming the win.
`_device_paths` listed the four data files a device publishes but not its
entry under `revs/`. `_sync_refresh._candidate_peers` deliberately enumerates
peers from the revision map instead of calling `list_directory` (that call is
~445ms mirrored), so every orphaned marker resurrected a deleted device as a
candidate and bought a round trip to discover it was gone. 19 orphans, 19
wasted probes, every tick.
The marker is now part of the device's path set, so it is backed up and
deleted with everything else, and a regression test pins it by name rather
than by count. The 19 already-orphaned markers on the remote were cleaned up
separately.
Verified after the cleanup: narrow pull ~84ms, full tick 8.9s (was ~27s),
gate --check ~105ms, and the log is untouched at 441 entries / 117 foods with
peer_revs correctly down to 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
Every install mints a uuid and pushes under it, and nothing ever cleans those
directories up. This remote holds 25: two live devices and 23 that stopped
writing. Each dead one still costs a round trip in the log pull and in each of
the three bank syncs -- roughly 400ms per peer per tick -- so they dominate
what is left of the background tick.
Diagnosis first, since the obvious theory was wrong. These are not the residue
of phone reinstalls: seventeen of them are frozen at the *same* instant
(2026-08-13T16:45:04) holding near-identical ~400-record copies of one log,
which is the signature of repeated fresh-install runs, not organic use. That
also means `adb install -r` preserving SharedPreferences was never the issue,
so no Android backup change is warranted on this evidence.
Dry-run by default, and the safety rails are the point:
* `plan_prune` only reads; it is what the default run prints.
* A device is prunable only when every record it holds is already held by a
device being kept. Against the live remote that is 20 devices and **zero**
records lost -- verified before writing the command, not after.
* `pc`/`phone`/`desktop` are never proposed: they carry pre-migration history
and `SYNC_LEGACY_DEVICE_ID` still names one as this device's own former path.
* `--apply` refuses without `--backup-dir`, and every peer's raw bytes are
written to disk before a single delete.
Nothing has been deleted; this commit only adds the tool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
`diet-guard log ...` blocked for ~15.5s after every meal. The publish itself
has to happen -- until it lands the phone still believes the slot is unlogged
and nags for it -- but only ~2.5s of that is the 293KB push the phone needs;
the rest is pulling peers' banks, which publishing does not require.
The local write is already durable before the publish starts, so the worst
case is a late publish, which is the trade `_sync_events` already documents
for the event-driven model. Moving it to a worker thread lets the caller
print and return at once. Measured: 15.5s -> 2ms before the prompt comes back.
The thread is deliberately non-daemon: the CLI's output is already flushed,
so the process lingers only as long as the push, and the publish is never
killed halfway. A failure still surfaces, via a callback the caller supplies,
just after its own output rather than in the middle of it.
Deliberately NOT applied to the gate. `diet-guard-gate.service` is
`Type=oneshot`, so systemd reaps the unit the moment the main thread exits
and a background publish would be killed mid-push. The gate keeps calling
`publish_after_log` and waiting, which is correct there -- its publish
already runs after the window is gone, where nobody is waiting on it.
`conftest._isolate_state` follows the rename to
`publish_after_log_detached`; without that every meal-logging test would
reach the network through a newly unpatched seam. Verified with
`scripts/check_patch_targets.py` and by timing the real command against a
temporary data dir, never the live log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
Two independent costs in the background tick, both pure waste.
`list_directory` was called four times per tick -- once by the log pull and
once by each of the three bank syncs. It is the single most expensive call
in a tick at ~445ms mirrored (the GitHub half alone is ~360ms), so that was
~1.3s spent asking the same question four times. It is now listed once in
`run_sync` and passed down.
The curated-bank loop was worse: 17s and 62 requests to fetch *zero* useful
bytes. Firebase returns None for every peer's `food_bank_manual.json`, so
`MirrorSyncClient.get_file_text` treated each miss as "this device has not
migrated yet" and fell through to GitHub, which answers `{}` after ~320ms.
`_primary_only_text` skips that per-peer fallthrough for this one artifact.
Checked before narrowing rather than assumed, because CLAUDE.md requires
both halves of the food bank to sync: reading every peer's curated bank
straight off the mirror found 23 absent, 2 empty, and zero non-empty. No
curated food exists on the mirror alone. The probe is written into the
docstring so it can be re-run before anyone widens this again.
Measured end to end: manual-bank loop 17.0s -> 3.4s, full tick ~27s -> 15.2s,
log/bank/peer state unchanged (441 entries, 117 foods, 22 peers).
`_pull_remote_logs` now returns the revisions it saw instead of mutating a
caller-supplied dict, which keeps it under the argument limit and makes the
data flow visible at the call site.
Deliberately NOT done: revision-skipping the derived food bank. `run_sync`
rebuilds that bank from the merged log immediately before syncing it, so
unlike the log the peer records are not durably held locally -- skipping a
peer would drop its counts from the pushed bank and oscillate under
max-count-wins. Verified empirically: the on-disk bank and a pure log-derived
rebuild are identical (117 foods, 0 differing counts), and the 2 foods peers
know that the local bank lacks are discarded by the existing `if name in
local` filter anyway. The loop is off every interactive path now, so the
remaining cost is background-only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
The button ran a full sync tick inline on the Tk thread, so the fullscreen
lock was dead for its whole duration -- ~18-27s measured against the live
remote, and unbounded in the worst case.
Two changes, and the second is what makes the first cheap:
* the pull moves to a daemon worker thread, with the result marshalled back
through a queue polled on the Tk thread. The worker touches no widget, by
construction: Tcl is not thread-safe and that includes `after`. Polling
also makes the completion path testable with no thread at all.
* the button now runs the *narrow* pull. Its question is identical to the
gate's -- "did a peer log this slot?" -- and `_reconcile_after_fetch`
reads only `due_slots()`, so the budget and food banks it used to fetch
were never consulted here. The full tick still runs after the window
closes. Measured 84ms against the live remote.
Narrowing also shrinks the write race from tens of seconds to imperceptible:
the pull persists its merge, so a meal logged mid-fetch could be overwritten
by a snapshot taken before it. A second click while a fetch is in flight is
now ignored, which is the reentrancy guard and the race fix in one.
That guard started out as a disabled button, which is how it surfaced a
latent bug worth recording: the fetch button widget is never stored on
`GateWidgets`, so `self._widgets.fetch_button` did not exist and the disable
was a silent no-op -- hidden because the suppression tuple included
`AttributeError`. Coverage would have reported 100% on a guard that did
nothing. Guarding on `_fetch_result` needs no widget at all.
Also adds `INTERACTIVE_TIMEOUT_SECONDS = 2.0` for the paths a user waits on.
`SYNC_TIMEOUT_SECONDS` only ever reached GitHub: crdt_sync v0.6.0's
`firebase_client_for` takes no timeout, so Firebase used its 15s default and
one hung session refresh stalled the lock window -- the journal has five such
refresh failures in five days. Verified to fail closed to the local decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
Diet_guard was one of the two lockers missing gatelock's
wait_for_turn fix -- confirmed live on 2026-08-21, it held the global
grab for ~3 minutes against a newly-arriving, higher-ranked
screen_locker, which just retried and logged instead of making
progress. Now queues behind any stronger claim before arming.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
The gate's pre-lock refresh ran a *full* sync tick -- peer logs, budget,
both food banks, a rebuild and a push -- in front of the fullscreen lock.
Measured against the live remote: ~18-27s, of which the curated-bank loop
alone was 17s spent fetching nothing (absent on Firebase for every peer, so
each of ~21 peers fell through to a GitHub round trip returning `{}`).
It was also failing: five `sync unavailable (network error trying to refresh
the session)` entries in the last five days, one of them a meal that logged
locally but never published.
The gate has one question -- "has a peer already logged this slot?" -- and
only the peer logs can answer it. `_sync_refresh` does that and nothing
else: one bulk revision-map read, a fetch of only the peers whose revision
moved, merge, re-sign, write. No budget, no banks, no push. The full tick
still runs, after the window closes, so nothing is permanently skipped --
the exhaustive pass just moves off the path the user waits on.
Three state invariants have their own regression tests, because each is
silent when broken: `pushed_rev` survives verbatim (this pass never pushes,
and overwriting it would make the device stop publishing forever),
`peer_revs` is merged rather than replaced (only changed peers are visited),
and the log is written before the state.
Separately, `gate --check` was importing an HTTP stack and a GUI it never
uses. `requests` (~78ms) arrived via `_cli_sync` -> `crdt_sync` and via
`_resolve` -> `_estimator_off`; both are now resolved through module-level
`__getattr__` (PEP 562) and `import_module`, which keeps every existing
`patch.object` target and needs no lint suppression.
Measured: `gate --check` 216ms -> ~110ms; narrow pull 92ms against the live
remote. Accepted trade, documented in `_sync_refresh`: a budget edited on
the phone is not visible in the lock dashboard until the post-window tick.
It cannot affect the lock decision, which is why it is safe to defer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
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).
The 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
Found on the phone, not in the suite. Setting five meals updated the settings
preview and fired a reminder for the new 14:00 checkpoint, while the slot row
behind them kept showing the old 08/12/16/20 until the app was restarted:
onOpenSettings pushed the route without awaiting the pop, so the log screen
never rebuilt against the schedule the user had just chosen.
It now awaits and calls onScheduleMayHaveChanged, which re-reads the logged
slots and re-derives the selected slot -- the hour it pointed at may not be a
checkpoint at all any more. The other three destinations still fire and
forget, since none of them can change what this screen renders.
The regression test was confirmed to fail against the pre-fix behaviour
("Found 0 widgets with text 11:00") rather than passing vacuously.
Both files crossed the 250-line cap with the addition, so the hook moved to
log_meal_sync_mixin.dart -- which already owns refreshSlots and loggedSlots
and is the better home regardless -- and the new test into its own file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM