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
Wires the Python side to the configurable schedule. No edit surface yet, so
nothing writes a schedule and current_schedule() returns the default -- the
derived hours are still 08/12/16/20 and the cutoff is still 22:00. Verified by
running it: before any edit the slots are unchanged, after recording 08-20 x5
they become 08:00, 11:00, 14:00, 17:00, 20:00, and a 2020 day still resolves
to the four-meal schedule.
_slots.py now takes the schedule as a *required* argument rather than reading
module constants. Required, not defaulted, on purpose: a default would let a
call site missed in this refactor keep deriving the old fixed hours on one
device, and a slot one device offers while the other does not is a checkpoint
that can never be satisfied. Making it required let mypy enumerate the nine
call sites instead of trusting a grep.
The history is forward-only and seeds the default at the epoch *before*
recording today's value, so switching to five meals leaves past days judged
against the four-meal schedule they actually ran under. Same shape and same
reasoning as _budget_history.py; it will ride the same CRDT record.
Two details worth naming:
- MEAL_SCHEDULE_FILE lands in DATA_DIR, so conftest's _isolate_state redirect
is patched in this same commit rather than a later one. Confirmed with a
canary that the redirect is live and that ~/.local/share/diet_guard/ has no
.meal_schedule after a full run.
- _mcp_read imports _budget.current_schedule already, so the meal-schedule
lookup is aliased rather than shadowing it.
_gatelock._pending_slots is spelled `is None` rather than `or` now. The two are
in fact equivalent -- if 0 is a slot it is necessarily the first slot, checked
exhaustively -- but the intent should not rest on that coincidence.
761 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
The periodic diet-guard-sync.timer paid a network round-trip every ~15 min to
approximate the two moments the shared log actually changes on this device.
Sync now fires on those events instead:
* before a lock -- already present in _cli_gate._should_lock, which pulls only
when a lock is otherwise due, so a phone-logged meal clears the lock and a
not-due tick never touches the network;
* after a meal is logged here -- new _sync_events.publish_after_log, wired into
all three write paths (_cli_log, _cli_gate, _mcp). This is what stops the
PHONE nagging for a slot already logged on the PC, the mirror image of the
rule that keeps the phone's own tick unconditional.
The gate's publish sits in cmd_gate AFTER MealGate.run() returns, never in the
unlock handler: that runs on the Tk thread with the window still up, so a
hanging network call would keep the user locked in behind a successful log. It
also covers the "Synced from another device" unlock, which hooking log_meal
would miss.
Accepted trade: a PC that is on but neither logging nor locking no longer
pulls, so an inbound phone edit lands at the next log or lock rather than
within 15 min. Bounded by slot spacing -- an unlogged day raises a lock.
install.sh now disables and removes the installed timer (guarded, since a fresh
machine has no such unit and `disable` would abort under `set -euo pipefail`);
the .service stays for manual one-off ticks.
Every test that logs a meal would otherwise reach the network through the new
call sites, so conftest patches publish_after_log at each of the three call
sites -- not on _sync_events, so test_sync_events still exercises the real
helper. Suite stays at ~8s, which is the actual leak detector.
Two files had to be split to commit at all, since file-length passes filenames
and both were already over the 250-line cap:
_cli.py 466 -> 207, + _cli_args.py, _cli_init.py, _cli_log.py
_mcp.py 370 -> 194, + _mcp_server.py (singleton + annotations), _mcp_read.py
Their conftest/test patch targets moved in the same commit; a stale target
redirects nothing and fails open.
Verified: 701 python tests at 100% branch coverage, 555 flutter tests,
`python -m diet_guard sync` -> 402 entries/exit 0, `gate --check` -> exit 0,
all five MCP tools still register, live ~/.local/share/diet_guard byte-identical.
Over-cap files: 44 -> 42.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
The chip was the only control that logged a meal against no slot at all
(`slot: null`). Removing it leaves the row with just the four hour chips,
so every logged meal now satisfies a real slot.
Storage keeps its null-slot support on purpose. Entries already on disk
and arriving over sync carry no `slot` key, and the `slot != null` filters
in log_storage_service.loggedSlotsToday / _state.logged_slots_today are
what stop them from retroactively satisfying meal slots -- so both are
documented as load-bearing rather than deleted as dead code.
Also corrects the MCP tool's docstring, which claimed its slot argument
"may be None early in the day"; it falls back to slot_for_log, which
clamps to a real hour and never returns None.
Adds the widget test the chip row never had -- being untested is why the
chip read as unused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPheLM86xUN9TGf34Ygtfz
The calendar already said which individual days went over budget, but not
whether the week as a whole did. A run of small overages reads as four
harmless yellow squares; averaged, it is the thing that actually moves the
needle.
Three rules are load-bearing, and are documented in CLAUDE.md because getting
any of them wrong silently changes what "under budget" means:
- The denominator is logged days, not elapsed days. An unlogged day is not a
zero-kcal day, and averaging gaps in as zeros would make a badly-tracked
week look like the best week of the year.
- The yardstick is the mean of the per-day budgets over those same days,
resolved through BudgetSchedule -- never today's budget, which would
retroactively reclassify past weeks on every budget edit.
- Today is excluded. A half-logged today drags the mean far enough to flip
the band, so every period ends at yesterday.
Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by
import rather than by copy, so a period and its days can never disagree
about what "over" means; that constant loses its underscore to say so.
The MCP get_averages tool returns the average and the band but drops the
average budget, keeping the module's existing invariant that the budget
number never leaves this machine. The CLI, the gate and the phone all show
both, since it is hidden from the network, not from the user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a
new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its
name, signature and returned shape. Verified against a clean 2.0.0 venv: all
four tools list, and the suite passes at 100% coverage, 609 tests.
Two things the port buys, neither of which existed in 1.x:
* Tool annotations, which matter more here than in the sibling repos because
this server has a tool that writes. The three readers are marked read-only,
idempotent and closed-world; log_meal is marked not-read-only but
explicitly NOT destructive -- it appends and never rewrites or deletes --
not idempotent, since a second confirmed call logs a second meal, and
open-world, because it reaches Open Food Facts when macros are not given.
A client can now tell the difference without reading the docstring.
* Structured output: 2.x derives an output schema from the return types and
sends structured content alongside the text. No code change -- it falls out
of the type hints that were already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
Four asks, plus the fixes two review passes turned up.
Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
was the sole trigger for. Replaced with a post-log progress card showing
kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
`components` field stays: nothing writes it now, but historical composite
entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
wrapper's /blobs/ route, image_picker, and the CAMERA permission.
Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
across Python and Dart. Deliberately separate from elapsed_slots, which
would otherwise make every slot fall due at 23:00.
Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
the budget in force then. Verified live: setting 2000 left a 36-day
adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
merges per-field over the union of field names and both devices push the
merged log, so a device that predates the feature relays them untouched --
no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
empty history after merging with a pre-feature peer, and a presence check
would then never seed again, silently reclassifying every past day.
One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
was collateral damage of whole-map LWW -- any phone budget edit deleted it,
and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
eating them) was the last device-local state. The derived half merges with
count as the clock, so LWW means max-count-wins; the log stays
authoritative for membership, because a CRDT union never shrinks and a
peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
a cast failure there is an Error, not an Exception, so one malformed
remote record would have broken phone sync permanently.
_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged
day coloring, logging/adherence streaks, year-to-date tally), and
tapping a day drills into that day's logged entries. This requires the
daily budget to become a normal, freely-editable value synced between
devices like the food log — replaces the previous chattr +i seal
(deliberately, per user decision) with plain last-edit-wins sync via a
new budget.json alongside food_log.json. The budget field defaults to
2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on
both the PC gate's History tab and the phone app's Calendar screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
Expose diet_guard's read surface and one gated write action as typed MCP
tools (diet_guard._mcp) so Claude Code and its subagents can query today's
intake and -- with explicit confirmation -- log a meal without shelling out
to the interactive gate CLI.
- _mcp.py: FastMCP stdio server. Read tools get_status, list_today,
get_slots expose today's CONSUMED kcal/macros and the qualitative
consumption band only -- never the raw daily budget, remaining number,
body weight, or protein target (those would back out the deliberately
hidden budget). Gated write log_meal previews unless confirm=True and
degrades gracefully on write failure. Nutrition is grouped into a Macros
model (kcal + optional protein/carbs/fat) to keep the tool under the
arg-count limits with no lint suppression, mirroring the CLI's grouping.
Logs to stderr; no secret (HMAC key / sync token) is ever returned.
- .mcp.json + scripts/setup_mcp.sh: registration via a dedicated
~/.venvs/diet-guard-mcp interpreter; mcp is an optional-dep, kept out of
the systemd/CLI system-python path.
- 100% coverage of the new module; verified end-to-end over stdio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v