Sync: EGG LIMIT hard rule + weekly cuisine rotation + scripts

- SKILL.md: add egg-limit hard rule (max 4/meal, one egg-meal/day), cuisine-rotation section
- scripts/pick_cuisine.py: seeded weekly world-cuisine picker (stable per week, no recent repeats)
- scripts/verify_eggs.py: regression guard for the egg cap
- README.md: full project documentation
- .gitignore: exclude cuisine-rotation.json + python caches (live data stays local)
This commit is contained in:
meal-agent
2026-08-27 19:07:53 +01:00
parent 9e448760cd
commit d8aa87921e
5 changed files with 614 additions and 43 deletions

5
.gitignore vendored
View File

@ -2,3 +2,8 @@
# Your current stock and 7-day meal log drift as you eat / the daily job runs. # Your current stock and 7-day meal log drift as you eat / the daily job runs.
inventory.json inventory.json
meal-history.json meal-history.json
# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source).
cuisine-rotation.json
# Python caches
__pycache__/
scripts/__pycache__/

361
README.md
View File

@ -1,49 +1,324 @@
# meal-suggestion skill # Meal Suggestion — Kitchen Inventory + High-Protein Meals
Kitchen-inventory + high-protein meal suggestion skill for Hermes Agent. An automated, research-grounded meal system for a single user (jp): a recovering
burns victim on a **high-protein diet (~190200 g/day)** who is also losing fat,
and who wants to **avoid buying red meat**. It does two things on a schedule:
- Tracks a kitchen inventory (`inventory.json`). 1. **Daily meal suggestions** — two meals (lunch + dinner) built only from what
- Emails **2 daily meal suggestions** (from stock) every day at 09:00. is actually in the kitchen inventory, emailed every day at 09:00.
- Emails a **weekly £30 shopping list** (Mondays 08:00) with a per-store 2. **Weekly shopping list** — a £30 Lidl basket, themed around a randomly chosen
price comparison (Lidl preferred) and a plain-text fallback. world cuisine that week, emailed Mondays at 08:00.
- Targets ~190200 g protein/day for a recovering burns victim (2 g/kg at
~95100 kg, age 46), avoids buying red meat, allows Amazon Prime + low-cost
condiments/flavourings/micronutrients when budget allows.
## Files Sender: `hpm6@txt3.com` (the agent's Gmail, via `smtp.gmail.com:587`).
- `SKILL.md` — the skill (rules, shop logic, 7-day history removal flow). Recipient: `jp@txt3.com`.
- `references/` — email template, protein sources, Eastbourne shop ratios,
meal-history logger spec, protein-target notes.
- `inventory.json`**LIVE DATA** (your current stock). Git-ignored; a
`inventory.example.json` is committed as a template.
- `meal-history.json`**LIVE DATA** (rolling 7-day meal log). Git-ignored.
## Setup > General guidance, **not medical advice** — defer to the user's clinical /
1. Copy the skill into your Hermes skills dir: > dietitian team; keep hydrated on a high-protein intake.
```
cp -r meal-suggestion ~/.hermes/skills/
```
2. Create `inventory.json` from the example:
```
cp ~/.hermes/skills/meal-suggestion/inventory.example.json \
~/.hermes/skills/meal-suggestion/inventory.json
```
3. Ensure the email sender exists at `~/.hermes/.env`:
```
EMAIL_ADDRESS=hpm6@txt3.com
EMAIL_PASSWORD="your-app-password"
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
```
4. Place the send helper:
`scripts/meal/send_meal_email.py` (reads creds from `~/.hermes/.env`,
supports `--subject --html --text --to`).
5. Create the two cron jobs (Hermes `cronjob` tool):
- Daily 09:00: `0 9 * * *` — load skill `meal-suggestion`, read inventory,
build 2 meals, email to `jp@txt3.com`, record into `meal-history.json`.
- Monday 08:00: `0 8 * * 1` — load skill `meal-suggestion`, build £30 Lidl
list + comparison, email to `jp@txt3.com`.
See `SKILL.md` for the full behavior spec and the removal-from-history flow. ---
> General guidance, not medical advice — defer to clinical/dietitian team. ## Table of contents
- [How it works](#how-it-works)
- [Repository layout](#repository-layout)
- [Core rules](#core-rules)
- [Egg limit (hard rule)](#egg-limit-hard-rule)
- [Weekly cuisine rotation](#weekly-cuisine-rotation)
- [Inventory & meal history](#inventory--meal-history)
- [Email format](#email-format)
- [Automation (cron)](#automation-cron)
- [Scripts](#scripts)
- [Where to shop](#where-to-shop-eastbourne)
- [On-demand usage](#on-demand-usage)
- [Testing & regression guards](#testing--regression-guards)
- [Git & sync](#git--sync)
---
## How it works
The system is driven by a **skill** (`SKILL.md`) plus two cron jobs that load it.
On each run the agent:
- Reads the kitchen **`inventory.json`** (current stock).
- **Daily:** searches the web for easy, tasty, high-protein budget recipes for
inspiration, then builds two meals **strictly from in-stock items**, records
them to `meal-history.json`, and emails them.
- **Weekly:** picks a cuisine, builds a £30 Lidl basket themed around it, adds a
per-store price comparison and a health-coverage note, and emails it.
All generated meals obey the **strict inventory rule**: no ingredient may appear
that is not already in `inventory.json`. Recipe ideas are only used as
inspiration; if a recipe needs something not in stock, it is substituted with an
in-stock item or dropped.
---
## Repository layout
```
meal-suggestion/
├── SKILL.md # The operative spec: rules, format, automation
├── README.md # This document
├── .gitignore # Excludes live data (inventory/meal-history/cuisine-rotation)
├── inventory.example.json # Schema example for inventory.json
├── meal-history.example.json # Schema example for meal-history.json
├── references/ # Knowledge base the agent reads when generating
│ ├── email-template.md # HTML email scaffold (inline CSS, mobile-friendly)
│ ├── food-health.md # Healing + 46yo health + easy/tasty meal research
│ ├── protein-sources.md # Allowed proteins + example pairings
│ ├── protein-targets.md # Protein math (2 g/kg)
│ ├── meal-history.md # 7-day logger spec + reconcile-from-inventory logic
│ └── eastbourne-shops.md # Shop price table (Lidl preferred)
├── scripts/
│ ├── pick_cuisine.py # Picks this week's world cuisine (seeded, varied)
│ └── verify_eggs.py # Regression guard for the EGG LIMIT hard rule
└── (live, git-ignored)
├── inventory.json # CURRENT stock — drifts as the user eats/buys
├── meal-history.json # 7-day rolling log of suggested meals
└── cuisine-rotation.json # Current + recent weekly cuisine picks
```
The live skill directory is `/home/jp/.hermes/skills/meal-suggestion/`; this repo
is a synced mirror under `~/IdeaProjects/meal-suggestion/`. The two stay in step
(see [Git & sync](#git--sync)).
---
## Core rules
- **High-protein target:** ~190200 g/day (≈2 g/kg at ~95100 kg, for burns
recovery + lean-mass preservation during ~15 kg fat loss). Aim ~90100 g per
meal and **state the per-meal and daily protein totals** in the email.
- **Weight-loss framing:** mild calorie deficit via bulking with veg and going
easy on chips/bread/oil — **not** by cutting protein.
- **Red meat:** already in stock is *usable* (use it up, deprioritised), but the
shopping list must **never suggest buying red meat** (beef, lamb, pork,
venison, bacon, gammon).
- **Strict inventory rule:** both daily meals are built ONLY from items in
`inventory.json`. Nothing invented, assumed, or added.
- **Tasty & varied:** the agent does a 12 query web search for easy, high-protein
budget meals and draws on real recipes so meals aren't the same plate repeated.
Prefers no-cook / one-pan / 1015 min methods.
- **Health coverage:** meals/items favour wound-healing nutrients (Vit C, Zinc,
Vit A, Copper, Vit K, iron+VitC) and 46-year-old health (Vit D, B12,
Magnesium, Omega-3) plus mental + visual acuity (leafy greens, eggs/choline,
berries, walnuts, olive oil, lutein/zeaxanthin, omega-3 DHA). Noted as gentle
"health boost" lines, not medical advice.
---
## Egg limit (hard rule)
Eggs were previously overloaded (a day suggested 8 eggs across two egg-bearing
meals). This is now a **hard cap**, enforced three ways:
1. **In SKILL.md:** *max 4 eggs per meal; only ONE of the two daily meals may
contain eggs — the other must be egg-free.* If a meal needs more protein than
4 eggs supply, close the gap with lentils/beans/halloumi/cheese/tinned fish/
whey — never a 5th+ egg. This overrides the "lead with eggs" preference.
2. **In inventory.json:** `diet.egg_rules` carries `max_per_meal: 4` and
`max_meals_per_day_with_eggs: 1`; the Eggs item note states the cap. Every run
reads the limit from the data itself.
3. **In scripts/verify_eggs.py:** a regression guard that checks the persisted
history against `diet.egg_rules` and exits non-zero on violation. Run it after
any change to inventory/meal-history or the daily job, and before sending a
regenerated day's email.
---
## Weekly cuisine rotation
To keep shops and meals varied over time, each week's list is themed around a
**randomly chosen world cuisine**, driven by `scripts/pick_cuisine.py`.
- **Stable within a week:** the pick is seeded by the week's Monday ISO date, so
re-runs / retries of the Monday job never flip the cuisine mid-week.
- **Varied over time:** the last 4 chosen cuisines are excluded from the pool
(no back-to-back repeats).
- **Persisted:** writes the choice to `cuisine-rotation.json`
(`week_monday`, `cuisine`, `theme_items`) so the **daily job can read it** and
cook on-theme meals from the same week's buys.
- **Cuisine pool (all Lidl-achievable on ~£30, high-protein, red-meat-free by
design):** Mexican/Tex-Mex, Greek/Mediterranean, Indian/South Asian, Thai, East
Asian/Japanese, Middle Eastern/Levantine, Italian, Korean.
- **Themes without breaking rules:** the cuisine leads protein/veg/condiment buys
with its `theme_items`, but still respects £30 budget, protein-first, healing/
46yo coverage, Lidl-default, and never-buy-red-meat. Pure flavour buys
(spices/sauces/lime/herbs) are the optional low-price "interest" items.
The weekly email subject/header shows `Cuisine: <name>`.
---
## Inventory & meal history
**`inventory.json`** — current stock. Schema:
```json
{
"updated": "YYYY-MM-DD",
"diet": {
"goal": "high-protein",
"avoid_purchase": ["red meat"],
"egg_rules": { "max_per_meal": 4, "max_meals_per_day_with_eggs": 1,
"note": "..." },
"age_years": 46, "bodyweight_kg": "95-100",
"protein_per_kg": 2, "protein_target_g_per_day": "190-200",
"weight_loss_goal_kg": 15
},
"items": [
{ "name": "Eggs", "qty": 10, "unit": "count", "category": "protein",
"notes": "Hard cap: max 4 eggs per meal; only ONE of the two daily meals may use eggs." }
]
}
```
- `category``protein, veg, fruit, dairy, grain, tinned, frozen, condiment, other`.
- **You own the inventory.** When the user says they *used/consumed* ingredients,
decrement/remove them (via the `patch` tool) and bump `updated`. When they
*bought* items, add/increase them. The daily email only *proposes* meals — it
does **not** auto-consume.
**`meal-history.json`** — rolling 7-day log. The daily job **UPSERTS** today's two
entries (replaces same `date`+`meal`, so re-runs don't duplicate) and **prunes**
entries older than 7 days. Each entry:
```json
{ "date": "YYYY-MM-DD", "weekday": "Thursday",
"meal": "lunch", "name": "Halloumi & Red Lentil Power Bowl (egg-free)",
"items": [{ "name": "Halloumi", "qty": 100, "unit": "g" }] }
```
Only substantive food items are logged — pure condiments and the optional
"third hit" snack are not. This log is what the on-demand inventory-removal
feature reconciles against.
---
## Email format
Sent via `python3 /home/jp/.hermes/scripts/meal/send_meal_email.py` (reads SMTP
creds from `~/.hermes/.env`). Both HTML (inline CSS, mobile-friendly, dark-on-light)
and a plain-text alternative are produced so the plan survives HTML-stripping
clients.
- **Daily:** header (date + `high-protein · ~190200g/day · no red meat bought`),
two meal cards (name, ingredients, 23 step method, protein total, short
"health boost" note), footer (`Reply to tell me what you used and I'll update
the inventory`).
- **Weekly:** shopping table (item, est. price, running total ≤ £30, Total row),
header with `Cuisine: <name>`, a "health coverage" note (healing + brain + eyes
+ 46yo + cuisine theme), a "Same basket — where else?" per-store comparison
table, and a "Same basket — where else?" plain-text mirror.
---
## Automation (cron)
Two cron jobs (created via the `cronjob` tool), both loading the
`meal-suggestion` skill:
| Job | Schedule | Purpose |
|-----|----------|---------|
| Daily meal suggestions | `0 9 * * *` | 2 in-stock meal suggestions → email |
| Weekly shopping list | `0 8 * * 1` | £30 cuisine-themed Lidl basket → email |
Delivery is `local` (the email is the deliverable); check `cronjob action=list` /
logs if a send seems missing. The weekly job runs `pick_cuisine.py` **first**,
then themes the basket; the daily job reads `cuisine-rotation.json` and prefers
on-theme cooking while still obeying the strict inventory rule and the egg cap.
---
## Scripts
### `scripts/pick_cuisine.py`
Picks this week's cuisine.
- `python3 scripts/pick_cuisine.py` — pick (or reuse this week's) and persist.
- `python3 scripts/pick_cuisine.py --dry-run` — print only, do not write.
- Prints JSON: `{"week_monday", "cuisine", "theme_items", "reused"}`.
- Seeded by the week's Monday → stable within a week; excludes the last 4
cuisines → no back-to-back repeats.
### `scripts/verify_eggs.py`
Regression guard for the EGG LIMIT contract. Reads `inventory.json`
(`diet.egg_rules`) and `meal-history.json`, checks no meal exceeds
`max_per_meal` and no day exceeds `max_meals_per_day_with_eggs`. Exits non-zero
on violation. Run after any change to the egg logic or before sending a
regenerated day.
---
## Where to shop (Eastbourne)
User preference (confirmed): **Lidl is the preferred shop** — a weekly trip, so
the extra distance is fine; best value and leaves budget for essential
micronutrients and the occasional treat. Others (Tesco Express, Sainsbury's,
Co-op, Londis) are comparison/fallback only.
Grounded in Which? 2026 (93-item basket; Lidl ≈ £160.70 baseline):
- Lidl ≈ 1.00x — cheapest, preferred
- Tesco superstore w/ Clubcard ≈ 1.17x; Tesco Express ≈ 1.22x
- Sainsbury's w/ Nectar ≈ 1.17x
- Co-op ≈ 1.30x (membership = annual dividend)
- Londis ≈ 1.251.35x (milk 2L £1.40 is a confirmed cheaper line)
The weekly email shows a "Same basket — where else?" comparison (Lidl / Tesco
Express Clubcard / Sainsbury's Nectar / Co-op member / Londis) as **estimates**
from the Which? 2026 ratios + known local prices — no live web search. Default is
a single Lidl shop; split only when a specific item's local price is *definitely*
known lower.
---
## On-demand usage
- **"What should I eat today?"** — read `inventory.json`, suggest now, offer to email.
- **"Remove Wednesday's dinner from inventory"** — resolve the entry, decrement
each logged item from `inventory.json` (remove if ~0), skip items not present,
bump `updated`, confirm. Works per-item and partial-consumption overrides too.
- **Adding stock** — list items in chat; they're added to `inventory.json` and an
immediate 2-meal suggestion can follow.
---
## Testing & regression guards
- `python3 scripts/verify_eggs.py` — asserts the EGG LIMIT holds across
`meal-history.json` using the persisted `diet.egg_rules`. Should print
`PASS: egg limit OK (max 4/meal, max 1 egg-meal/day)`.
- The cuisine picker is deterministic per week (Monday-seeded) and was verified to
produce no consecutive repeats across 8 simulated weeks.
---
## Git & sync
This repo (`~/IdeaProjects/meal-suggestion/`) is the version-controlled mirror of
the live skill (`/home/jp/.hermes/skills/meal-suggestion/`).
- **Live data is git-ignored:** `inventory.json`, `meal-history.json`,
`cuisine-rotation.json`, and `__pycache__/`. Only the spec, references,
examples, and scripts are committed.
- **Sync workflow:** copy the changed source files (currently `SKILL.md` and
`scripts/`) from the live skill dir into this repo, then commit. Keep
`.gitignore` excluding live data in both places.
```bash
# from /home/jp/.hermes/skills/meal-suggestion
cp SKILL.md /home/jp/IdeaProjects/meal-suggestion/SKILL.md
cp scripts/pick_cuisine.py scripts/verify_eggs.py \
/home/jp/IdeaProjects/meal-suggestion/scripts/
# commit the mirror
cd /home/jp/IdeaProjects/meal-suggestion
git add -A && git commit -m "Sync: egg-limit hard rule + weekly cuisine rotation"
git push
```
Version history of the mirror:
- `72ce56e` Initial meal-suggestion skill (logic + template; live data git-ignored)
- `c5f4fb1` Sync: food-health research, mackerel, easy/tasty + acuity, weekly health-coverage
- `9e44876` Sync: strict inventory-only rule for daily meals
- *(current)* Sync: EGG LIMIT hard rule + weekly cuisine rotation + scripts

View File

@ -45,6 +45,7 @@ Schema:
## Meal suggestion rules ## Meal suggestion rules
- Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory. - Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory.
- **Maximize protein** per meal; lead with a protein source. - **Maximize protein** per meal; lead with a protein source.
- **EGG LIMIT (hard rule):** Eggs are capped at **4 per meal** and **only ONE of the two daily meals may contain eggs** — the other meal MUST be egg-free. Never suggest more than 4 eggs in a single meal, and never put eggs in both meals. If a meal needs more protein than 4 eggs supply, close the gap with other proteins (lentils, beans, halloumi, cheese, tinned fish, whey) or a whey shake — do NOT add a 5th+ egg. This overrides the "lead with eggs" preference below.
- **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the - **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the
weekly shopping list must NEVER suggest red meat to buy. weekly shopping list must NEVER suggest red meat to buy.
- **If red meat is already in the inventory, it stays usable** in daily meal - **If red meat is already in the inventory, it stays usable** in daily meal
@ -53,6 +54,7 @@ Schema:
- Preferred proteins (lead with these): eggs, chicken, turkey, fish, prawns, tofu, - Preferred proteins (lead with these): eggs, chicken, turkey, fish, prawns, tofu,
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk, tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
whey, tinned tuna/salmon, edamame, halloumi. whey, tinned tuna/salmon, edamame, halloumi.
(Eggs are subject to the EGG LIMIT hard rule above: max 4 per meal, only one meal/day may include them.)
- **Protein target:** ~**190200 g/day** (user ~95100 kg × 2 g/kg — burns recovery + - **Protein target:** ~**190200 g/day** (user ~95100 kg × 2 g/kg — burns recovery +
preserving lean mass while losing ~15 kg fat). Aim each meal at **~90100 g protein** preserving lean mass while losing ~15 kg fat). Aim each meal at **~90100 g protein**
and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't
@ -103,8 +105,35 @@ header, the two meal cards, and a footer note
("Reply to tell me what you used and I'll update the inventory"). ("Reply to tell me what you used and I'll update the inventory").
Use the scaffold in `references/email-template.md`. Use the scaffold in `references/email-template.md`.
## Weekly cuisine rotation
To keep shops and meals varied over time, each week's list is themed around a
**randomly chosen world cuisine**. This is driven by
`scripts/pick_cuisine.py` (same skill dir).
- Run `python3 scripts/pick_cuisine.py` at the **start of the weekly job**. It
picks a cuisine seeded by the week's Monday, so it is **stable within a week**
(re-runs/retries never flip the cuisine mid-week) but **varies week to week**
and avoids the last 4 cuisines (no back-to-back repeats). It prints JSON with
`cuisine` and `theme_items`, and persists the choice to `cuisine-rotation.json`.
- The chosen cuisine must **theme the entire weekly basket**: lead the protein,
veg, and 12 condiment/flavour buys with that cuisine's `theme_items`, so the
shop introduces varied ingredients. Cuisines are all Lidl-achievable on ~£30,
high-protein, and **red-meat-free by design**.
- Do NOT abandon the core rules for the theme: still hit the protein target,
still cover healing/brain/eyes/46yo micronutrients where possible, still
default to Lidl, still never buy red meat, still keep the £30 budget. Theme
items that are just flavour (spices, sauces, lime, herbs) are the optional
"interest" buys — keep them low-price and behind protein + veg + staples.
- Persist the active cuisine so the **daily meal job can read `cuisine-rotation.json`
and on-theme meals from the same week's buys** (e.g. build a Korean bowl, a
Mexican bowl, etc., from stock). The daily job should still respect the STRICT
inventory rule — only cook with what's actually in inventory.json.
- The rotation file keeps a short history; no manual tracking needed.
## Weekly shopping list (Mondays) ## Weekly shopping list (Mondays)
- Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's). - Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's).
- **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and
theme the basket around it — varied ingredients keep meals interesting.
- Prioritize protein, then veg/fruit, then staples. Show item, est. price, and a - Prioritize protein, then veg/fruit, then staples. Show item, est. price, and a
running total that lands in range. running total that lands in range.
- **APPLY the research** (`references/food-health.md`): build the basket so it covers - **APPLY the research** (`references/food-health.md`): build the basket so it covers
@ -201,11 +230,54 @@ garlic, soy, etc.) and the optional "third hit" snack are NOT logged. Units matc
6) Confirm what was removed. The user can also say "remove all of <day>" to drop both 6) Confirm what was removed. The user can also say "remove all of <day>" to drop both
meals, or name a specific item to override. meals, or name a specific item to override.
**Item-level overrides & partial consumption (apply BEFORE step 3 above):**
- If the user corrects a quantity for one item (e.g. "only used 100g halloumi, not
200g"), use their stated qty instead of the logged qty for that item.
- If the user says they only used PART of an item and the rest remains (e.g. "still
got half a lettuce", "there's half a head of broccoli left"), do NOT remove the whole
inventory entry — keep/restore the remaining portion (re-add at e.g. 0.5 count, or
leave prior-qty minus what was used). Never zero out an item the user says they still
have. Log the restored amount back into `inventory.json` and re-sort.
**Reconcile other meal-history entries AFTER removal (prevents stale future plans):**
The daily job only writes TODAY's two meals at 09:00 — it never pre-plans future days.
But `meal-history.json` may contain future-dated entries (pre-seeded, or written by a
re-run). After any removal that deletes an inventory item (hits ~0), scan EVERY entry
in `meal-history.json` — including future-dated ones inside the 7-day window — for
`items` that reference the now-absent inventory name. For each such entry:
- Flag it to the user: "<Day> <meal> ('<name>') still lists <gone item> but it's no
longer in inventory."
- Offer to rewrite that entry using ONLY in-stock items (substitute the missing item
for an in-stock alternative, or drop it). Do NOT silently leave a plan that can't be
cooked.
This is the only guard against "Thursday's plan used chicken, but I ate the chicken
today" — the design is correct; the stale future row is the anomaly, so fix the row,
not the design. See `references/reconcile-history.md` for the scan snippet.
## On-demand (chat) ## On-demand (chat)
If the user lists ingredients in chat, add them to the inventory and optionally If the user lists ingredients in chat, add them to the inventory and optionally
give an immediate 2-meal suggestion. If they ask "what should I eat today?", read give an immediate 2-meal suggestion. If they ask "what should I eat today?", read
the inventory and suggest now (and offer to email it). the inventory and suggest now (and offer to email it).
## Pitfalls (learned the hard way)
- **"Regenerate / resend today's meals" means GENERATE FRESH, not replay.** When the
user asks to regenerate a day's meals or re-send the email, do NOT pull the two meals
back out of `meal-history.json` and re-format them. Re-read `inventory.json`, build
two new meals from current stock (honouring the egg limit, STRICT inventory rule, etc.),
then update today's `meal-history.json` entries to match what you actually send.
The history is a log; it is not the source of truth for a regeneration request.
- **Editing a cron job's prompt: edit jobs.json AND push via the `cronjob update`
action.** The scheduler may hold jobs in memory, so a bare file edit is not enough —
the running daemon won't see it. After writing the new prompt string, call
`cronjob update job_id=<id> prompt="..."` so the in-memory job reloads. Verify with
`cronjob list` that the preview reflects the change. (Pushing to a non-running daemon
is harmless; not pushing is the silent-failure trap.)
- **The egg rule is a HARD cap, not a preference.** The daily job leaned on eggs as the
"preferred protein" and produced 8 eggs across two meals (6 + 4). Cured by (a) the
EGG LIMIT prose rule above, (b) `diet.egg_rules` in inventory.json, and (c) running
`scripts/verify_eggs.py` after any history/inventory change or before a resend.
Always run that probe before sending a regenerated day.
## Automation ## Automation
Two cron jobs (created via the `cronjob` tool), both loading this skill: Two cron jobs (created via the `cronjob` tool), both loading this skill:
- Daily 09:00 — `0 9 * * *` — 2 meal suggestions. - Daily 09:00 — `0 9 * * *` — 2 meal suggestions.
@ -214,6 +286,14 @@ Two cron jobs (created via the `cronjob` tool), both loading this skill:
Delivery is `local` because the email itself is the deliverable; check Delivery is `local` because the email itself is the deliverable; check
`cronjob action=list` / logs if a send ever seems missing. `cronjob action=list` / logs if a send ever seems missing.
## Scripts (in this skill dir)
- `scripts/pick_cuisine.py` — picks the week's random world cuisine (stable per week,
varies weekly, no recent repeats); persists to `cuisine-rotation.json`. Used by the
Monday weekly job (see "Weekly cuisine rotation").
- `scripts/verify_eggs.py` — regression probe for the EGG LIMIT. Run after any change to
`meal-history.json`, `inventory.json`, or the daily job, and before sending a
regenerated day's email. Exits non-zero on violation. See "Pitfalls".
## References ## References
- `references/protein-sources.md` — allowed proteins + example pairings. - `references/protein-sources.md` — allowed proteins + example pairings.
- `references/email-template.md` — HTML email scaffold. - `references/email-template.md` — HTML email scaffold.

143
scripts/pick_cuisine.py Normal file
View File

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Pick this week's world cuisine for the weekly shop.
Design goals:
- RANDOM per week, but STABLE within a week (seeded by the week's Monday date)
so that re-runs / retries of the Monday job never flip the cuisine mid-week.
- VARIED over time: the last few chosen cuisines are excluded from the pool.
- PERSISTED to cuisine-rotation.json so the daily meal job can read the active
cuisine and cook on-theme meals from the same week's buys.
Usage:
python3 pick_cuisine.py # pick (or reuse) and persist
python3 pick_cuisine.py --dry-run # print only, do not write
Prints a JSON line:
{"week_monday": "...", "cuisine": "...", "theme_items": [...], "reused": bool}
"""
import argparse
import json
import os
from datetime import date, timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
ROTATION_FILE = os.path.join(HERE, "..", "cuisine-rotation.json")
# Curated cuisines: all are achievable at Lidl on a ~£30 budget, high-protein,
# and contain NO red meat by design. theme_items are cheap, cuisine-appropriate
# buys (proteins/veg/staples/condiments) that add variety; some may already be
# in inventory — the weekly job still cross-checks budget + Lidl availability.
CUISINES = {
"Mexican / Tex-Mex": [
"wholemeal tortilla wraps", "tinned black beans", "tinned sweetcorn",
"tinned chopped tomatoes", "cheddar cheese", "lime", "cumin",
"smoked paprika", "avocado", "chicken breast", "Greek yogurt",
],
"Greek / Mediterranean": [
"feta", "Kalamata olives", "cucumber", "cherry tomatoes",
"chickpeas", "halloumi", "dried oregano", "lemon", "red onion",
"tinned tuna", "Greek yogurt",
],
"Indian / South Asian": [
"red lentils", "chickpeas", "tinned tomatoes", "fresh spinach",
"curry powder", "grated ginger", "onions", "Greek yogurt",
"firm tofu", "microwave rice", "chicken breast",
], "Thai": [
"jasmine rice", "coconut milk (tin)", "soy sauce", "lime",
"grated ginger", "red curry paste", "frozen prawns", "edamame",
"fish-free: tofu", "fresh coriander", "eggs",
],
"East Asian / Japanese": [
"egg noodles", "soy sauce", "edamame (frozen)", "eggs",
"firm tofu", "sesame seeds", "tinned tuna", "miso paste",
"frozen mixed veg", "grated ginger", "spring onions",
],
"Middle Eastern / Levantine": [
"chickpeas", "red lentils", "feta", "cucumber", "tomatoes",
"lemon", "cumin", "pitta breads", "halloumi", "Greek yogurt",
"fresh parsley", "chicken breast",
],
"Italian": [
"passata", "wholemeal pasta", "mozzarella", "tinned tuna",
"cherry tomatoes", "fresh basil", "black olives", "spinach",
"eggs", "chicken breast", "parmesan",
],
"Korean": [
"jasmine rice", "soy sauce", "sesame oil", "sesame seeds",
"gochujang (tube)", "eggs", "firm tofu", "kimchi (jar)",
"frozen mixed veg", "grated ginger", "spring onions",
],
}
# How many recent cuisines to exclude so weeks don't repeat back-to-back.
RECENT_WINDOW = 4
def week_monday(d=None):
d = d or date.today()
return d - timedelta(days=d.weekday()) # Monday=0
def load_rotation():
try:
with open(ROTATION_FILE) as f:
return json.load(f)
except FileNotFoundError:
return {"current": None, "history": []}
def save_rotation(data):
os.makedirs(os.path.dirname(ROTATION_FILE), exist_ok=True)
with open(ROTATION_FILE, "w") as f:
json.dump(data, f, indent=2)
def pick(dry_run=False):
import random
monday = week_monday()
monday_str = monday.isoformat()
rot = load_rotation()
# Same week -> reuse the locked-in cuisine (stable within the week).
if rot.get("current") and rot["current"].get("week_monday") == monday_str:
rot["current"]["reused"] = True
print(json.dumps(rot["current"]))
return
pool = list(CUISINES.keys())
recent = [h["cuisine"] for h in rot.get("history", [])[-RECENT_WINDOW:]]
candidates = [c for c in pool if c not in recent] or pool
# Seed by the week's Monday so the pick is deterministic for that week
# (re-runs in the same week always return the same cuisine).
rng = random.Random(monday_str)
cuisine = rng.choice(candidates)
chosen = {
"week_monday": monday_str,
"cuisine": cuisine,
"theme_items": CUISINES[cuisine],
"reused": False,
}
if dry_run:
print(json.dumps(chosen))
return
history = rot.get("history", [])
history.append({"week_monday": monday_str, "cuisine": cuisine})
# Keep history bounded.
history = history[-12:]
save_rotation({"current": chosen, "history": history})
print(json.dumps(chosen))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true",
help="print the pick without persisting")
args = ap.parse_args()
pick(dry_run=args.dry_run)
if __name__ == "__main__":
main()

68
scripts/verify_eggs.py Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Regression guard for the EGG LIMIT hard rule.
Run after any change to meal-history.json, inventory.json, or the daily job,
and before sending a regenerated day's email:
python3 scripts/verify_eggs.py
Checks the EGG LIMIT contract against the persisted rules in inventory.json:
- No single meal may exceed diet.egg_rules.max_per_meal (default 4).
- No day may have more than diet.egg_rules.max_meals_per_day_with_eggs
(default 1) meals containing eggs — i.e. the other meal must be egg-free.
Exits non-zero on violation so it can gate a send or a CI-style check.
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)
def load(name):
with open(os.path.join(SKILL_DIR, name)) as f:
return json.load(f)
def main():
inv = load("inventory.json")
hist = load("meal-history.json")
rules = inv.get("diet", {}).get("egg_rules", {})
max_per_meal = rules.get("max_per_meal", 4)
max_egg_meals = rules.get("max_meals_per_day_with_eggs", 1)
violations = []
by_date = {}
for e in hist.get("entries", []):
eggs = next((i["qty"] for i in e["items"] if i["name"].lower() == "eggs"), 0)
by_date.setdefault(e["date"], []).append((e["meal"], e.get("name", ""), eggs))
for date, meals in by_date.items():
for meal, name, eggs in meals:
if eggs > max_per_meal:
violations.append(
f"{date} {meal} ('{name}'): {eggs} eggs > max {max_per_meal}/meal")
egg_meal_count = sum(1 for _, _, eggs in meals if eggs > 0)
if egg_meal_count > max_egg_meals:
violations.append(
f"{date}: {egg_meal_count} egg-containing meals > max {max_egg_meals}/day "
f"(other meal must be egg-free)")
# Also confirm the inventory metadata carries the cap.
if not rules:
violations.append("inventory.json missing diet.egg_rules — egg cap not enforced at the data level")
if violations:
print("FAIL: egg-limit violations found:")
for v in violations:
print(f" - {v}")
sys.exit(1)
print(f"PASS: egg limit OK (max {max_per_meal}/meal, max {max_egg_meals} egg-meal/day) "
f"across {len(by_date)} day(s).")
if __name__ == "__main__":
main()