due_slots asks sickdays.stands_down(LOCKER_DIET, day) instead of
is_sick_day, so a sick day that keeps diet is a normal day for the gate.
Bump sickdays pins to v0.2.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
due_slots() returns nothing on a sick day, like a free day: no lock, no debt;
logging still works. Unreadable pool reads as not sick (fail closed). Pins
sickdays-v0.1.0 and freedays-v0.4.0 (pyproject, requirements, pylint hook env).
Phone gap documented in docs/DOCS-sick-days.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
An inert dropdown arrow looked broken. ttk.Spinbox posts nothing; every
spin path (arrow press, Up/Down, wheel) generates <<Increment>>/
<<Decrement>>, bound here to step_time from the typed value (07:23 -> up
07:30, down 07:15) with "break" so ttk's values/wrap logic never runs.
First clamps to 00:00-23:45, last to 00:15-23:45. Popdown-blocking code
removed; FakeSpinbox replaces FakeCombobox.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
- The popdown maps beneath the gatelock surface yet holds ttk's grab
(verified on Xvfb: invisible, swallows the next click, steals keys), so
arrow clicks are intercepted as Down already was. Typing and Up/Down stay.
- Catering dedup compares the entry's recorded minute (parity with the
app); occupancy (fill_plan) still snaps.
- Test: a corrupt peer slot (slot 24, slot_min 5000) re-signs and reads
without raising.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
Observed on Xvfb with the demo gate: the ComboboxPopdown is viewable but
stacked under the gatelock surface, so typing and Up/Down are the paths.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
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
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
`_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 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
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
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.
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
The rules that are expensive to rediscover: integer-only arithmetic and why
(Python and Dart round differently), the count clamp and why a duplicate slot
hour is worse than it looks, the last+2h cutoff and its midnight clamp, and
the required-argument decision in _slots.py / slot.dart.
Split into docs/meal-schedule.md rather than inlined -- CLAUDE.md hit 276
lines against the 250 cap, the same split the Flutter rules already use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
CLAUDE.md was 1101 lines and loaded into every session in this repo. Three
passes, in order: automate what a gate can check, rewrite what is left as
instructions rather than rationale, extract what still does not fit.
Automated. Five prohibitions were enforced by nothing but a paragraph asking a
future reader not to do something; they are now repo_invariants_test.dart:
- no app/linux/ (a GTK embedder target manages ~20fps at 4K)
- dart:io only in *_io.dart seams and lib/desktop/ -- this one is why the
test exists at all, since dart:io does not fail a web *compile*. It throws
at runtime, so a bad import is a blank white window that the widget tests
happily pass through.
- no meal builder / repeat-last-meal / reward prompt
- no image_picker, no CAMERA permission
- wrapper port 8732 and the diet-guard-desktop Chrome profile, both pinned
because IndexedDB is keyed by origin and moving either hides the whole
local food log
Each guard was canary-tested by breaking the invariant and confirming the
suite goes red -- a guard that cannot fail is decoration.
Rules whose behaviour was already covered (budget epoch seeding, sync merge,
food bank) shrink to a pointer at the test that owns them.
Where a rule's entire value was its rationale ("the removal is the feature"),
the why moved into the name and reason string of the gate that now enforces
it, so it lives somewhere executable instead of somewhere quotable.
Extracted. The vendored flutter/flutter rules were 789 of the 1101 lines --
72% of the file, none of it about this repo. Split into five docs/ topic files
under the cap and linked. Verified content-complete: 664 non-blank lines in,
664 out, zero missing.
Python 692 tests / 100% coverage unchanged; Flutter 548 -> 555 (+7 new).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
Rewrites python_pkg.diet_guard imports to diet_guard, vendors the
shared as_float coercion helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
scaffolds standalone lint/test config matching testsAndMisc's real
enforced bar (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage).