prompts.chatprompts.chatprompts.chat
BotsPromptsSkillsWorkflowsCategoriesTagsPromptmastersTaste
Developers
LoginRegister
CC0 2026 prompts.chat
GitHub
All Tags

Python Prompts

76 prompts
Time Zone Meeting Slot Finder
Skill

Finds fair meeting times for distributed teams. Takes each participant's IANA time zone, working days and hours, how far they will stretch, holidays and blocked time, then lists every slot in a date range in each person's local time, handles daylight-saving switches that happen on different weekends, and picks a rotating series so the same people do not always take the early or late call. Includes a stdlib Python zoneinfo script with --self-test.

---
name: time-zone-meeting-slot-finder
description: Finds fair meeting times for distributed teams. Takes each participant's IANA time zone, working days and hours, how far they will stretch, holidays and blocked time, then lists every slot in a date range in each person's local time, handles daylight-saving switches that happen on different weekends, and picks a rotating series so the same people do not always take the early or late call. Includes a stdlib Python zoneinfo script with --self-test.
---

# Time Zone Meeting Slot Finder

You help distributed teams pick meeting times that are possible for everyone required and fair over time. You work in each person's own local time on the actual dates (never with "LA is 9 hours behind Berlin" rules of thumb, which are wrong for weeks every spring and autumn), show the trade-offs openly, and rotate the inconvenient slots instead of always putting them on the same people.

## Files in this skill

- `scripts/slot_finder.py` - reads a team CSV and optional blocked-time CSV, evaluates every slot on a grid over the date range in each participant's local time (Python zoneinfo), ranks slots by who is missing and how much stretch they need, reports DST changes in the range, and can pick a fair rotating series (Python 3.9+ standard library only)
- `references/dst-and-time-zone-pitfalls.md` - DST dates by region, zones that do not switch, half-hour zones, weekends that are not Sat-Sun, abbreviations to avoid, and how calendar invites behave across a switch
- `references/fairness-and-rotation.md` - the pain-point model, how the rotation chooses, when to split a meeting or go async instead, and how to explain the choice to the team
- `templates/slot-proposal.md` - the proposal to send to the team
- `examples/team.csv` - five people in Los Angeles, New York, Berlin, Istanbul and Kolkata (one optional)
- `examples/blocked.csv` - a public holiday, a dentist appointment, a board review and a day of leave

## Workflow

### 1. Collect the facts
For each participant: name, IANA zone (city based, e.g. `America/Los_Angeles`, never "PST"), working days (Mon-Fri, or Sun-Thu in much of the Middle East), working hours, how many hours they are willing to start earlier or finish later, and whether they are required. Also the date range, meeting length, how often it repeats, known holidays and blocked times (in that person's local time). Ask rather than guess zones from a city that has several (e.g. a "Mexico" team member).

### 2. Run the finder
```
python3 scripts/slot_finder.py --team examples/team.csv --blocked examples/blocked.csv --start 2026-10-26 --end 2026-11-20 --days Tue,Wed,Thu --series 4
python3 scripts/slot_finder.py --team team.csv --start 2026-11-02 --end 2026-11-06 --duration 30 --step 15 --top 5
python3 scripts/slot_finder.py --team team.csv --start 2026-11-02 --end 2026-11-27 --series 8 --every day --ref-zone Europe/Berlin --json
python3 scripts/slot_finder.py --self-test
```
Dates (`--start`, `--end`, `--days`) are read in `--ref-zone` (default UTC). Exit 0 means slots were found, 1 means no slot works for every required person, 2 means bad input (unknown zone, bad time, missing column).

### 3. Read the result
- **DST changes in range** come first: if any zone switches inside the range, the same UTC time lands an hour earlier or later for those people afterwards. Propose slots per period, not one UTC time for the whole range.
- **Best slots** are ranked by: optional people missing, then the worst single person's stretch, then total stretch.
- **Rotating series** picks one meeting per week (or per day) to keep the worst cumulative inconvenience as low as possible, then spreads it. The summary shows how often each person met in hours, stretched or missed.
- If there is no slot, say why (who blocks whom) and offer options: more stretch from volunteers, a shorter meeting, making someone optional, two regional meetings, or async updates.

### 4. Deliver
Fill `templates/slot-proposal.md`: the recommendation with every person's local time and weekday, the DST note, the rotation table, alternatives, and the open questions. Send invites from a calendar with a real zone (the organizer's IANA zone), not "UTC" unless the team agreed, and say which zone anchors the series.

## Example result
Five people, 2026-10-26 to 2026-11-20, Tue-Thu, 60 minutes, series of 4 weekly meetings. Europe left summer time on 25 October and the US on 1 November, so in the week of 26 October New York is only 5 hours behind Berlin instead of 6. Week 1: 13:30 UTC (Ana 06:30 in Los Angeles, a stretch; Priya 19:00 in Kolkata, a stretch; everyone else in hours). After the US change, two weeks at 16:00 UTC (Ana 08:00 in hours, Eva 17:00-18:00 stretch, Bilal 19:00-20:00 stretch) and one at 14:30 UTC (Ana stretches again). Bilal is blocked on 29 October (Republic Day). Priya (optional, +05:30) can join only before the US change; the proposal adds a recorded summary for her and suggests an Asia-friendly slot once a month.

## Rules

- Always use IANA zone names and compute local times per date; never apply fixed offsets across a DST boundary.
- Show every participant's local time and weekday for each proposal, since the date can differ (Kolkata evening, Los Angeles morning).
- Nobody is scheduled outside the stretch they agreed to; ask before assuming someone will take a 06:00 or 22:00 call.
- Rotate inconvenience and show the tally; if one person always loses, say so and suggest a structural fix (split, async, rotate the meeting owner).
- Respect weekends, public holidays and blocked time in the person's own calendar and zone.
- Keep the recommendation short; put the full table in an appendix.
FILE:references/dst-and-time-zone-pitfalls.md
# DST and time zone pitfalls

## Use IANA zone names
- `America/New_York`, `Europe/Berlin`, `Asia/Kolkata`, `Australia/Sydney`. They carry the full history and future rules of that region.
- Avoid abbreviations: "EST", "CST", "IST" and "BST" are ambiguous (IST = India, Ireland or Israel; CST = US Central, China or Cuba) and the zoneinfo entries named EST or MST are fixed offsets with no DST. The script warns on names without a "/".
- Avoid fixed offsets like UTC+1 for people: offsets change twice a year in many places.
- Pick the city that shares the person's rules: Arizona (`America/Phoenix`) has no DST; most of Mexico dropped DST in 2022; `America/Sao_Paulo` dropped DST in 2019.
- Keep the zone database current (`tzdata`); governments change rules with a few weeks' notice.

## DST dates (2026, check each year)
| Region | Summer time starts | Ends | Note |
|---|---|---|---|
| US and Canada (most) | 2nd Sunday of March (2026-03-08) | 1st Sunday of November (2026-11-01) | local 02:00 |
| EU, UK | last Sunday of March (2026-03-29) | last Sunday of October (2026-10-25) | 01:00 UTC, all at once |
| Australia (south-east), New Zealand | first Sunday of October / last Sunday of September | first Sunday of April | southern hemisphere: opposite season |
| No DST | Turkey (UTC+3), India (+05:30), China, Japan, Singapore, UAE, most of Africa, Arizona | | |

So there are two "gap" periods each year when the US-Europe difference is one hour smaller than usual: about 3 weeks in March and 1 week at the end of October / start of November. Recurring meetings anchored in one region move by an hour for the other during those weeks.

## Offsets that are not whole hours
- India +05:30, Iran +03:30, Nepal +05:45, Newfoundland -03:30 (-02:30 in summer), South Australia +09:30/+10:30, Chatham Islands +12:45. A grid on the hour in UTC lands on :30 or :45 for these people; the script evaluates in 5-minute chunks so these are exact.

## Weekends and working days
- Sun-Thu working weeks: e.g. Israel; Fri-Sat weekends in several Gulf countries, while the UAE moved to Mon-Fri (with a half Friday) in 2022. Ask each person.
- Public holidays differ per country and per region (Bavaria vs Berlin, US states). Add them to the blocked file in local dates.
- A Monday 08:00 slot in Los Angeles is Monday evening in India but already Tuesday morning in Sydney or Auckland. Always show the weekday per person.

## DST on the switch day itself
- Spring forward: local times between 02:00 and 03:00 (US) do not exist; autumn back: 01:00-02:00 happens twice. Avoid scheduling in those hours on switch days; the script works on UTC instants so it never produces an invalid time, but humans reading "01:30" on that day can be confused.

## Calendar invites across a switch
- A recurring invite is anchored to the organizer's zone. A 10:00 New York weekly meeting stays at 10:00 in New York and moves for Berlin during the gap weeks.
- If the series must stay fair for both sides, anchor it in UTC deliberately and tell everyone, or plan the gap weeks explicitly.
- Check the invite's zone after creating it (some tools default to the device zone or to "floating" time).
FILE:references/fairness-and-rotation.md
# Fairness and rotation

## Pain-point model used by the script
| Situation for one person in one meeting | Points |
|---|---|
| Entirely inside their working hours | 0 |
| Partly or fully in their stretch window (before or after work, as much as they agreed to) | 2 per hour of stretch, pro rata (30 min = 1) |
| Outside the stretch window, on a non-working day, or during blocked time | cannot attend (slot invalid if they are required) |
| Optional person who cannot attend (rotation tally only) | 3 |

Ranking of single slots: fewest optional people missing, then the lowest worst-off person, then the lowest total.

Rotation (`--series N`): greedily adds one meeting at a time, at most one per ISO week (`--every week`, default) or per date (`--every day`), choosing the slot that keeps the highest cumulative score lowest, then the sum of squared cumulative scores (which spreads pain instead of concentrating it), then fewer people missing. The summary table shows per person: meetings in hours, stretched, missed, total points.

The weights are a model, not a truth. Adjust them with the team: an early start may cost a parent more than a late finish, a 22:00 call costs more than 18:30. Change stretch windows per person to reflect what they actually accept.

## Make the trade-offs visible
- Show each person's local time and weekday for each option.
- Show the rotation tally, so that "Bilal stretches 3 of 4 times" is a visible decision, not an accident.
- Ask people to set their own stretch windows; never assume someone will take a 06:30 call because they did it once.

## When no fair slot exists
- Shorten the meeting: 30 minutes often fits where 60 does not.
- Make people optional and record the meeting with written notes and a decision log.
- Split by region (Americas + Europe, Europe + Asia) with one person bridging, and rotate the bridge.
- Alternate two time slots week by week (e.g. 14:00 UTC and 07:00 UTC) so each side gets the convenient one half the time.
- Go async: written updates, recorded demos, decisions in a doc with a deadline in everyone's local time.

## Checklist before sending
- Every required person is in hours or in their agreed stretch for every meeting in the series.
- Holidays and leave in each country are in the blocked file.
- DST changes in the range are named, and the series accounts for them.
- The invite is created in the agreed anchor zone; times in the message are given per person.
- There is a review date (e.g. after the next DST change) to rotate again.
FILE:templates/slot-proposal.md
# Meeting time proposal: <meeting name>

**Who:** <Ana (Los Angeles), Dana (New York), Eva (Berlin), Bilal (Istanbul), Priya (Kolkata, optional)>
**Length / cadence:** <60 min weekly, Tue-Thu>   **Range checked:** <2026-10-26 to 2026-11-20>
**Anchor zone for the invite:** <UTC / organizer's zone>

## Recommendation
| Week | Start (UTC) | Ana | Dana | Eva | Bilal | Priya |
|---|---|---|---|---|---|---|
| <Oct 26> | <Tue 13:30> | <Tue 06:30 (stretch)> | <09:30> | <14:30> | <16:30> | <19:00 (stretch)> |
| <Nov 2> | <Wed 16:00> | <08:00> | <11:00> | <17:00 (stretch)> | <19:00 (stretch)> | <cannot (21:30)> |

## Daylight saving
- <Europe left summer time on Oct 25, the US on Nov 1: during the week of Oct 26 New York is 5 h behind Berlin instead of 6, so the slot moves by an hour from Nov 2>

## Fairness over the series
| Name | In hours | Stretch | Missed | Points |
|---|---|---|---|---|
| <Ana> | <2> | <2> | <0> | <4> |

<One sentence on who carries the most and what we do about it (e.g. Priya: recorded summary + an Asia-friendly slot monthly).>

## Alternatives
1. <30-minute version at 15:00 UTC: everyone in hours except ...>
2. <alternate two slots week by week>

## Blocked dates taken into account
- <Oct 29: Republic Day, Turkey (Bilal)>

## Please confirm
- <your stretch window is still right>
- <holidays or leave in this range we missed>
FILE:examples/team.csv
name,zone,work_start,work_end,days,flex_before,flex_after,required
Ana,America/Los_Angeles,08:00,16:30,Mon-Fri,1.5,1,yes
Dana,America/New_York,08:30,17:00,Mon-Fri,1,1,yes
Eva,Europe/Berlin,09:00,17:30,Mon-Fri,1,1.5,yes
Bilal,Europe/Istanbul,09:00,18:00,Mon-Fri,0,2,yes
Priya,Asia/Kolkata,10:00,18:30,Mon-Fri,0,2,no
FILE:examples/blocked.csv
name,date,start,end,reason
Bilal,2026-10-29,,,Republic Day (public holiday in Turkey)
Dana,2026-11-03,10:00,11:30,dentist
Eva,2026-11-05,15:30,17:30,quarterly board review
Priya,2026-11-06,,,leave
FILE:scripts/slot_finder.py
#!/usr/bin/env python3
"""Time zone meeting slot finder (Python 3.9+ standard library: zoneinfo).

Given participants with IANA time zones, working days and hours (plus how far each one
is willing to stretch before or after work), finds meeting slots over a date range,
ranks them by fairness, and can pick a rotating series so the same people do not take
the early or late call every time. Each slot is evaluated in every participant's own
local time on that date, so daylight-saving transitions inside the range (Europe and
the US switch on different weekends) are handled automatically and reported.

Pain points per participant and meeting: 0 inside working hours, 2 per hour inside the
stretch ("flex") window before or after work, unavailable beyond that, on non-working
days or during blocked time. Missing an optional participant counts as 3 pain points
for them in the rotation tally.

Usage:
  python3 slot_finder.py --team team.csv --start 2026-10-26 --end 2026-11-06
        [--duration 60] [--step 30] [--blocked blocked.csv] [--days Tue,Wed,Thu]
        [--ref-zone UTC] [--top 10] [--series N [--every week|day]] [--json]
  python3 slot_finder.py --self-test
team.csv columns: name, zone, work_start, work_end, days (Mon-Fri or Sun-Thu or
Mon,Wed,Fri), flex_before, flex_after (hours, optional), required (yes/no, optional).
work_end earlier than work_start means a shift that runs past midnight.
blocked.csv columns: name, date (participant's local date), start, end (empty = all day), reason.
Exit codes: 0 = slots found, 1 = no slot where all required participants can attend,
2 = bad input (unknown time zone, bad time, missing file or column).
"""
import argparse, csv, datetime as dt, io, json, sys
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
CHUNK = 5  # minutes; fine enough for +05:30 and +05:45 zones
FLEX_PAIN_PER_HOUR = 2
MISSED_PAIN = 3


class InputError(Exception):
    pass


def parse_days(s):
    s = (s or "Mon-Fri").strip()
    out = set()
    for part in s.replace(" ", "").split(","):
        if not part:
            continue
        if "-" in part:
            a, b = part.split("-", 1)
            i, j = day_index(a), day_index(b)
            k = i
            while True:
                out.add(k)
                if k == j:
                    break
                k = (k + 1) % 7
        else:
            out.add(day_index(part))
    return out


def day_index(name):
    n = name.strip()[:3].title()
    if n not in DAY_NAMES:
        raise InputError(f"unknown day '{name}' (use Mon..Sun)")
    return DAY_NAMES.index(n)


def parse_hm(s, what):
    try:
        h, m = s.strip().split(":")
        h, m = int(h), int(m)
        if not (0 <= h <= 24 and 0 <= m < 60) or (h == 24 and m):
            raise ValueError
        return h * 60 + m
    except (ValueError, AttributeError):
        raise InputError(f"bad time '{s}' for {what} (use HH:MM, 24-hour)")


def load_zone(name, who, warnings):
    name = (name or "").strip()
    try:
        z = ZoneInfo(name)
    except (ZoneInfoNotFoundError, ValueError):
        raise InputError(f"unknown time zone '{name}' for {who}: use an IANA name such as America/New_York, Europe/Berlin, Asia/Kolkata (pip install tzdata if the system has no zone database)")
    if "/" not in name and name not in ("UTC", "Etc/UTC"):
        warnings.append(f"{who}: '{name}' is an abbreviation or legacy zone with fixed rules; use the city-based IANA name (e.g. America/New_York, not EST) so daylight saving is right")
    return z


def read_team(text, warnings):
    rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
    if not rows:
        raise InputError("team file has no rows")
    for col in ("name", "zone", "work_start", "work_end"):
        if col not in rows[0]:
            raise InputError(f"team file is missing column '{col}'")
    team = []
    for r in rows:
        who = (r.get("name") or "").strip()
        if not who:
            continue
        p = {"name": who, "zone_name": r["zone"].strip(), "zone": load_zone(r["zone"], who, warnings),
             "start": parse_hm(r["work_start"], who + " work_start"), "end": parse_hm(r["work_end"], who + " work_end"),
             "days": parse_days(r.get("days")),
             "flex_before": int(round(float(r.get("flex_before") or 0) * 60)),
             "flex_after": int(round(float(r.get("flex_after") or 0) * 60)),
             "required": (r.get("required") or "yes").strip().lower() not in ("no", "n", "false", "0", "optional"),
             "blocked": []}
        if p["start"] == p["end"]:
            raise InputError(f"{who}: work_start equals work_end")
        team.append(p)
    names = [p["name"] for p in team]
    if len(set(names)) != len(names):
        raise InputError("duplicate participant names in team file")
    return team


def read_blocked(text, team):
    byname = {p["name"].lower(): p for p in team}
    for r in csv.DictReader(io.StringIO(text.lstrip("\ufeff"))):
        who = (r.get("name") or "").strip()
        if not who:
            continue
        p = byname.get(who.lower())
        if not p:
            raise InputError(f"blocked time for unknown participant '{who}'")
        try:
            d = dt.date.fromisoformat(r["date"].strip())
        except (ValueError, KeyError):
            raise InputError(f"bad date in blocked file for {who}: {r.get('date')}")
        s, e = (r.get("start") or "").strip(), (r.get("end") or "").strip()
        a = dt.datetime.combine(d, dt.time()) + dt.timedelta(minutes=parse_hm(s, who + " blocked start") if s else 0)
        b = dt.datetime.combine(d, dt.time()) + dt.timedelta(minutes=parse_hm(e, who + " blocked end") if e else 1440)
        p["blocked"].append((a, b, (r.get("reason") or "").strip()))


def classify(p, local):
    """local: naive local datetime. Return 'core', 'flex' or None (unavailable)."""
    best = None
    for back in (0, 1):
        d = local.date() - dt.timedelta(days=back)
        if d.weekday() not in p["days"]:
            continue
        base = dt.datetime.combine(d, dt.time())
        cs = base + dt.timedelta(minutes=p["start"])
        ce = base + dt.timedelta(minutes=p["end"] + (1440 if p["end"] <= p["start"] else 0))
        if cs <= local < ce:
            return "core"
        if cs - dt.timedelta(minutes=p["flex_before"]) <= local < ce + dt.timedelta(minutes=p["flex_after"]):
            best = "flex"
    return best


def evaluate(p, start_utc, minutes):
    """Return (status, pain, local_start, local_end, reason). status in core, flex, out."""
    flex_min = 0
    reason = ""
    for k in range(0, minutes, CHUNK):
        t = (start_utc + dt.timedelta(minutes=k)).astimezone(p["zone"]).replace(tzinfo=None)
        c = classify(p, t)
        if c is None:
            reason = "outside hours" if t.date().weekday() in p["days"] or classify_day_any(p, t) else "day off"
            return "out", 0.0, *local_span(p, start_utc, minutes), reason
        for a, b, why in p["blocked"]:
            if a <= t < b:
                return "out", 0.0, *local_span(p, start_utc, minutes), f"blocked: {why}" if why else "blocked"
        if c == "flex":
            flex_min += CHUNK
    pain = round(flex_min / 60 * FLEX_PAIN_PER_HOUR, 2)
    return ("flex" if flex_min else "core"), pain, *local_span(p, start_utc, minutes), ""


def classify_day_any(p, t):
    return (t.date() - dt.timedelta(days=1)).weekday() in p["days"] and p["end"] <= p["start"]


def local_span(p, start_utc, minutes):
    a = start_utc.astimezone(p["zone"])
    b = (start_utc + dt.timedelta(minutes=minutes)).astimezone(p["zone"])
    return a, b


def dst_changes(team, start, end):
    out = []
    seen = set()
    for p in team:
        if p["zone_name"] in seen:
            continue
        seen.add(p["zone_name"])
        z = p["zone"]
        t = dt.datetime.combine(start - dt.timedelta(days=1), dt.time(), tzinfo=dt.timezone.utc)
        stop = dt.datetime.combine(end + dt.timedelta(days=2), dt.time(), tzinfo=dt.timezone.utc)
        prev = t.astimezone(z).utcoffset()
        while t < stop:
            t2 = t + dt.timedelta(minutes=30)
            off = t2.astimezone(z).utcoffset()
            if off != prev:
                loc = t2.astimezone(z)
                out.append({"zone": p["zone_name"], "utc": t2.strftime("%Y-%m-%d %H:%M UTC"),
                            "local": loc.strftime("%Y-%m-%d %H:%M %Z"), "from": fmt_off(prev), "to": fmt_off(off)})
                prev = off
            t = t2
    return out


def fmt_off(td):
    m = int(td.total_seconds() // 60)
    s = "+" if m >= 0 else "-"
    m = abs(m)
    return f"UTC{s}{m // 60:02d}:{m % 60:02d}"


def find_slots(team, start, end, duration, step, ref_zone, only_days=None):
    """start/end: dates in ref_zone (inclusive). Returns list of slot dicts."""
    slots = []
    t = dt.datetime.combine(start, dt.time(), tzinfo=ref_zone).astimezone(dt.timezone.utc)
    stop = dt.datetime.combine(end + dt.timedelta(days=1), dt.time(), tzinfo=ref_zone).astimezone(dt.timezone.utc)
    t = t.replace(minute=(t.minute // step) * step if step < 60 else 0, second=0, microsecond=0)
    while t + dt.timedelta(minutes=duration) <= stop:
        ref_local = t.astimezone(ref_zone)
        if only_days is None or ref_local.weekday() in only_days:
            per, ok = {}, True
            for p in team:
                st, pain, a, b, why = evaluate(p, t, duration)
                per[p["name"]] = {"status": st, "pain": pain, "local_start": a, "local_end": b, "reason": why}
                if st == "out" and p["required"]:
                    ok = False
                    break
            if ok:
                missing = [p["name"] for p in team if per[p["name"]]["status"] == "out"]
                pains = [per[p["name"]]["pain"] for p in team]
                slots.append({"utc": t, "per": per, "missing": missing, "total_pain": round(sum(pains), 2),
                              "max_pain": max(pains)})
        t += dt.timedelta(minutes=step)
    return slots


def rank(slots):
    return sorted(slots, key=lambda s: (len(s["missing"]), s["max_pain"], s["total_pain"], s["utc"]))


def period_key(s, ref_zone, every):
    d = s["utc"].astimezone(ref_zone).date()
    return d.isocalendar()[:2] if every == "week" else d


def pick_series(slots, team, n, ref_zone, every="week"):
    """Greedy fair rotation: n slots, at most one per ref-zone ISO week (every="week") or
    per date (every="day"), minimising the worst cumulative pain, then the sum of squared
    cumulative pain, then missed people."""
    cum = {p["name"]: 0.0 for p in team}
    chosen, used_dates = [], set()
    for _ in range(n):
        best, best_key = None, None
        for s in slots:
            d = period_key(s, ref_zone, every)
            if d in used_dates:
                continue
            new = {k: cum[k] + (MISSED_PAIN if s["per"][k]["status"] == "out" else s["per"][k]["pain"]) for k in cum}
            key = (round(max(new.values()), 2), round(sum(v * v for v in new.values()), 2), len(s["missing"]), s["total_pain"], s["utc"])
            if best_key is None or key < best_key:
                best, best_key = s, key
        if best is None:
            break
        chosen.append(best)
        used_dates.add(period_key(best, ref_zone, every))
        for k in cum:
            cum[k] += MISSED_PAIN if best["per"][k]["status"] == "out" else best["per"][k]["pain"]
    chosen.sort(key=lambda s: s["utc"])
    return chosen, cum


def cell(info):
    a, b = info["local_start"], info["local_end"]
    txt = f"{DAY_NAMES[a.weekday()]} {a:%H:%M}-{b:%H:%M}"
    if info["status"] == "flex":
        txt += f" (stretch, {info['pain']:g})"
    elif info["status"] == "out":
        txt += f" (cannot: {info['reason']})"
    return txt


def table(slots, team, ref_zone):
    head = ["#", f"Start ({ref_zone.key})"] + [f"{p['name']} ({p['zone_name']})" for p in team] + ["Pain total/max", "Missing"]
    out = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
    for i, s in enumerate(slots, 1):
        r = s["utc"].astimezone(ref_zone)
        row = [str(i), f"{DAY_NAMES[r.weekday()]} {r:%Y-%m-%d %H:%M}"] + [cell(s["per"][p["name"]]) for p in team] + \
              [f"{s['total_pain']:g}/{s['max_pain']:g}", ", ".join(s["missing"]) or "-"]
        out.append("| " + " | ".join(row) + " |")
    return out


def report(team, slots, args, ref_zone, warnings, changes, series=None, cum=None):
    out = [f"# Meeting slots {args['start']} to {args['end']} ({args['duration']} min, dates in {ref_zone.key})", ""]
    for w in warnings:
        out.append(f"> WARNING: {w}")
    if warnings:
        out.append("")
    out.append("## Participants")
    out.append("| Name | Zone | Hours (local) | Days | Stretch before/after | Required |")
    out.append("|---|---|---|---|---|---|")
    for p in team:
        days = ",".join(DAY_NAMES[d] for d in sorted(p["days"]))
        out.append(f"| {p['name']} | {p['zone_name']} | {p['start'] // 60:02d}:{p['start'] % 60:02d}-{p['end'] // 60:02d}:{p['end'] % 60:02d} | {days} | {p['flex_before'] / 60:g}h / {p['flex_after'] / 60:g}h | {'yes' if p['required'] else 'no'} |")
    out += ["", "## Daylight saving changes in range"]
    if changes:
        for c in changes:
            out.append(f"- {c['zone']}: {c['from']} -> {c['to']} at {c['local']} ({c['utc']}); the same UTC time moves by an hour locally after this")
    else:
        out.append("- none")
    out.append("")
    if not slots:
        out.append("No slot found where every required participant is available. Options: allow more stretch hours, make someone optional, shorten the meeting, split into two regional meetings, or go async.")
        return "\n".join(out)
    if series:
        out += [f"## Rotating series ({len(series)} meetings, fairest combination found by greedy rotation)", ""] + table(series, team, ref_zone)
        out += ["", "| Name | In hours | Stretch | Missed | Pain total |", "|---|---|---|---|---|"]
        for p in team:
            st = [s["per"][p["name"]]["status"] for s in series]
            out.append(f"| {p['name']} | {st.count('core')} | {st.count('flex')} | {st.count('out')} | {cum[p['name']]:g} |")
        out.append("")
    top = rank(slots)[:args["top"]]
    out += [f"## Best {len(top)} of {len(slots)} possible slots", ""] + table(top, team, ref_zone)
    out += ["", f"Pain: 0 inside working hours, {FLEX_PAIN_PER_HOUR} per hour of stretch, {MISSED_PAIN} for an optional person who cannot attend (rotation only)."]
    return "\n".join(out)


def to_json(team, slots, series, cum, changes, warnings, ref_zone, top):
    def enc(s):
        return {"start_utc": s["utc"].strftime("%Y-%m-%dT%H:%MZ"), "start_ref": s["utc"].astimezone(ref_zone).isoformat(),
                "total_pain": s["total_pain"], "max_pain": s["max_pain"], "missing": s["missing"],
                "participants": {k: {"status": v["status"], "pain": v["pain"], "local_start": v["local_start"].isoformat(),
                                     "local_end": v["local_end"].isoformat(), "reason": v["reason"]} for k, v in s["per"].items()}}
    return {"warnings": warnings, "dst_changes": changes, "slot_count": len(slots),
            "best": [enc(s) for s in rank(slots)[:top]], "series": [enc(s) for s in series or []], "series_pain": cum or {}}


def run(team_text, start, end, duration=60, step=30, blocked_text=None, ref="UTC", top=10, series=0, only_days=None, every="week"):
    warnings = []
    team = read_team(team_text, warnings)
    if blocked_text:
        read_blocked(blocked_text, team)
    ref_zone = load_zone(ref, "--ref-zone", warnings)
    if end < start:
        raise InputError("--end is before --start")
    if duration % CHUNK or step % CHUNK or duration <= 0 or step <= 0:
        raise InputError(f"--duration and --step must be positive multiples of {CHUNK} minutes")
    slots = find_slots(team, start, end, duration, step, ref_zone, only_days)
    chosen, cum = pick_series(slots, team, series, ref_zone, every) if series else (None, None)
    changes = dst_changes(team, start, end)
    return team, slots, chosen, cum, changes, warnings, ref_zone


def self_test():
    T = "name,zone,work_start,work_end,days,flex_before,flex_after,required\n"
    D = dt.date
    passed, total = 0, 0

    def check(name, cond):
        nonlocal passed, total
        total += 1
        passed += bool(cond)
        print(("PASS " if cond else "FAIL ") + name)

    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    check("Berlin+London overlap is 09:00-16:00 UTC (13 one-hour starts on the 30-min grid)", len(slots) == 13 and slots[0]["utc"].hour == 9 and slots[-1]["utc"].hour == 15)
    check("all in-hours slots have zero pain", all(s["total_pain"] == 0 for s in slots))
    team, slots, *_ = run(T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,0,0,yes\nB,Asia/Kolkata,09:00,18:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 6))
    check("LA and Kolkata without stretch: no slot", not slots)
    team, slots, *_ = run(T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,2,0,yes\nB,Asia/Kolkata,09:00,18:00,Mon-Fri,0,3,yes\n", D(2026, 11, 2), D(2026, 11, 6), duration=30)
    check("with 2h/3h stretch: only 30-min slots (15:00 UTC) exist and all carry pain", slots and all(s["total_pain"] > 0 for s in slots))
    s0 = rank(slots)[0]
    check("half-hour zone: Kolkata local minutes end in :30", s0["per"]["B"]["local_start"].minute == 30)
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 10, 26), D(2026, 10, 26))
    first = slots[0]["utc"]
    check("Berlin-NY in the gap week (EU back on CET, US still EDT): overlap 13:00-16:00 UTC", first.hour == 13 and slots[-1]["utc"].hour == 15)
    team, slots2, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    check("after US DST end: overlap starts 14:00 UTC", slots2[0]["utc"].hour == 14)
    *_, changes, w, rz = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 10, 24), D(2026, 11, 3))
    zones = {c["zone"]: c for c in changes}
    check("DST changes found: Berlin 2026-10-25 and New York 2026-11-01", "2026-10-25" in zones.get("Europe/Berlin", {}).get("local", "") and "2026-11-01" in zones.get("America/New_York", {}).get("local", ""))
    team, slots, *_ = run(T + "A,Asia/Dubai,09:00,17:00,Sun-Thu,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 6), D(2026, 11, 8))
    check("Sun-Thu vs Mon-Fri: no Fri/Sat/Sun slot", not slots)
    team, slots, *_ = run(T + "A,Asia/Dubai,09:00,17:00,Sun-Thu,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 9), D(2026, 11, 9))
    check("Sun-Thu vs Mon-Fri: Monday works", bool(slots))
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/Paris,09:00,17:00,Mon-Fri,0,0,no\n", D(2026, 11, 2), D(2026, 11, 2),
                          blocked_text="name,date,start,end,reason\nB,2026-11-02,,,holiday\n")
    check("optional person blocked all day: slots still found, B missing", slots and all(s["missing"] == ["B"] for s in slots))
    check("blocked reason shown", slots and "holiday" in slots[0]["per"]["B"]["reason"])
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/Paris,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2),
                          blocked_text="name,date,start,end,reason\nB,2026-11-02,10:00,12:00,dentist\n")
    check("required person blocked 10-12 local: no slot overlaps 09:00-11:00 UTC", slots and not any(dt.time(8, 30) < s["utc"].time() < dt.time(11, 0) for s in slots))
    team, slots, *_ = run(T + "N,Europe/Berlin,22:00,06:00,Mon-Fri,0,0,yes\n", D(2026, 11, 3), D(2026, 11, 3))
    hours = sorted({s["utc"].hour for s in slots})
    check("overnight shift 22:00-06:00 Berlin (UTC+1): starts 00:00-04:00 and 21:00-23:00 UTC", hours == [0, 1, 2, 3, 4, 21, 22, 23])
    w = []
    load_zone("EST", "X", w)
    check("abbreviation zone gives a warning", w and "abbreviation" in w[0])
    try:
        load_zone("Mars/Olympus", "X", [])
        ok = False
    except InputError as e:
        ok = "unknown time zone" in str(e)
    check("unknown zone is an input error", ok)
    try:
        parse_hm("9am", "x")
        ok = False
    except InputError:
        ok = True
    check("bad time is an input error", ok)
    check("day ranges wrap: Fri-Mon", parse_days("Fri-Mon") == {4, 5, 6, 0})
    team3 = T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,2,0,yes\nB,Europe/Istanbul,09:00,18:00,Mon-Fri,0,2,yes\n"
    team, slots, chosen, cum, *_ = run(team3, D(2026, 11, 2), D(2026, 11, 6), series=4, every="day")
    st = {k: sum(1 for s in chosen if s["per"][k]["status"] == "flex") for k in ("A", "B")}
    check("series of 4 on 4 different dates", len(chosen) == 4 and len({s["utc"].date() for s in chosen}) == 4)
    check("rotation shares the stretch: both A and B stretch at least once", st["A"] >= 1 and st["B"] >= 1)
    check("rotation pain is balanced (difference <= 2 points)", abs(cum["A"] - cum["B"]) <= 2)
    team, slots, chosen, cum, *_ = run(team3, D(2026, 10, 26), D(2026, 11, 20), series=4)
    check("weekly series: one meeting in each of 4 ISO weeks", len({s["utc"].isocalendar()[1] for s in chosen}) == 4)
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 6), only_days={1, 3})
    check("--days Tue,Thu filters candidate days", slots and {s["utc"].weekday() for s in slots} == {1, 3})
    team, slots, ch, cum, changes, w, rz = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    text = report(team, slots, {"start": "2026-11-02", "end": "2026-11-02", "duration": 60, "top": 3}, rz, w, changes)
    check("report renders table", "| 1 | Mon 2026-11-02 08:00 |" in text and "Daylight saving" in text)
    j = to_json(team, slots, None, None, changes, w, rz, 3)
    check("json output has best slots", len(j["best"]) == 3 and j["best"][0]["start_utc"] == "2026-11-02T08:00Z")
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Find fair meeting slots across time zones")
    ap.add_argument("--team")
    ap.add_argument("--blocked")
    ap.add_argument("--start")
    ap.add_argument("--end")
    ap.add_argument("--duration", type=int, default=60)
    ap.add_argument("--step", type=int, default=30)
    ap.add_argument("--days", help="only these weekdays in --ref-zone, e.g. Tue,Wed,Thu")
    ap.add_argument("--ref-zone", default="UTC")
    ap.add_argument("--top", type=int, default=10)
    ap.add_argument("--series", type=int, default=0, help="pick N meetings with rotating inconvenience")
    ap.add_argument("--every", default="week", choices=["week", "day"], help="series spacing: one meeting per ISO week (default) or per day")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not (a.team and a.start and a.end):
        ap.print_usage(sys.stderr)
        print("error: --team, --start and --end are required", file=sys.stderr)
        return 2
    try:
        start, end = dt.date.fromisoformat(a.start), dt.date.fromisoformat(a.end)
        team_text = open(a.team, encoding="utf-8").read()
        blocked_text = open(a.blocked, encoding="utf-8").read() if a.blocked else None
        only = parse_days(a.days) if a.days else None
        team, slots, chosen, cum, changes, warnings, rz = run(team_text, start, end, a.duration, a.step, blocked_text, a.ref_zone, a.top, a.series, only, a.every)
    except (InputError, OSError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps(to_json(team, slots, chosen, cum, changes, warnings, rz, a.top), indent=2))
    else:
        print(report(team, slots, {"start": a.start, "end": a.end, "duration": a.duration, "top": a.top}, rz, warnings, changes, chosen, cum))
    return 0 if slots else 1


if __name__ == "__main__":
    sys.exit(main())
PythonMeetingsTime Management+2
F@f
0
DNS Zone Record Linter
Skill

Lints a DNS zone file or a provider's CSV record export before it breaks web or mail - CNAME at the apex or next to other records, MX/NS pointing at a CNAME or IP, missing trailing dots, missing or duplicate SPF, +all/?all and the 10-lookup limit, DMARC and DKIM presence, syntax and key size, TTL extremes, private IPs and SOA mistakes. Returns corrected records. Includes an offline stdlib Python linter with --self-test.

---
name: dns-zone-record-linter
description: Lints a DNS zone file or a provider's CSV record export before it breaks web or mail - CNAME at the apex or next to other records, MX/NS pointing at a CNAME or IP, missing trailing dots, missing or duplicate SPF, +all/?all and the 10-lookup limit, DMARC and DKIM presence, syntax and key size, TTL extremes, private IPs and SOA mistakes. Returns corrected records. Includes an offline stdlib Python linter with --self-test.
---

# DNS Zone Record Linter

You review DNS records the way a careful DNS operator would before a migration or a mail setup goes live. You find the mistakes that silently break things (a CNAME at the apex, an MX that points at a CNAME, `mail.example.com` written without its trailing dot, two SPF records), explain the effect in plain words, and hand back corrected records. You work offline from the file; you never claim a live lookup you did not make.

## Files in this skill

- `scripts/dns_lint.py` - parses a BIND zone file ($ORIGIN, $TTL, @, relative names, multi-line SOA, quoted TXT) or a CSV export (name,type,value,ttl,priority) and reports findings with line numbers, then lists out-of-zone targets to check live (Python 3.8+ standard library only)
- `references/record-rules.md` - the rules behind each check with RFC pointers: CNAME exclusivity, MX/NS targets, trailing dots, SPF, DMARC, DKIM, TTL and SOA values
- `references/finding-codes.md` - every finding code with severity, when it is acceptable, and the usual fix
- `templates/dns-review.md` - the review to hand back (summary, fixes as records, live checks, rollout notes)
- `examples/example.com.zone` - a zone exported before a DNS host move, with mistakes on purpose
- `examples/example.com.fixed.zone` - the corrected zone (lints clean)
- `examples/lint-report.md` - the real output of the linter on the broken example

## Workflow

### 1. Get the records and the context
Ask for the zone file (BIND format, `dig AXFR` output or the provider's export) or a CSV (`name,type,value,ttl,priority`), the zone name, and what the domain is for: website only, sending mail (which services: Google Workspace, Microsoft 365, a newsletter tool, a helpdesk), or no mail at all. If the user pastes records from a provider UI, note that host names there usually have no trailing dot and that NS/SOA are often managed by the provider.

### 2. Run the linter
```
python3 scripts/dns_lint.py examples/example.com.zone
python3 scripts/dns_lint.py records.csv --origin example.com
python3 scripts/dns_lint.py zone.db --origin example.com --min-severity MEDIUM --ignore CAA_MISSING --json
python3 scripts/dns_lint.py --self-test
```
Exit 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unclosed parenthesis or quote, unknown zone name). If you cannot run code, apply `references/record-rules.md` by hand in the same order.

### 3. Read the findings
- **HIGH** breaks resolution or mail now (CNAME conflicts, MX to CNAME, missing trailing dot, two SPF records, `+all`, DMARC at the wrong name, invalid SPF terms, weak DKIM key).
- **MEDIUM** is wrong or risky but may still work (missing DMARC/DKIM/SPF, `?all`, private IPs, TTL extremes, dangling targets, a single name server).
- **LOW** is hygiene (duplicates, `p=none`, 1024-bit DKIM, no CAA, obsolete SPF type).
- The linter counts only top-level SPF lookups; nested includes add more. It cannot see records that live at the provider (DKIM selectors created by a mail host) or targets outside the zone. Say so, and list the live checks from the "Out-of-zone targets" table (`dig +short CNAME shop.example.com`, `dig TXT _dmarc.example.com`).

### 4. Deliver
Fill `templates/dns-review.md`: a short summary, the corrected records in the user's format (zone lines or provider form fields), what to verify live, and the rollout order: lower TTLs a day ahead, change, verify, raise TTLs again; bump the SOA serial. For mail changes, recommend DMARC `p=none` with `rua` first, then `quarantine`, then `reject` once reports are clean.

## Example result
On `examples/example.com.zone` the linter reports 19 findings (HIGH 7, MEDIUM 8, LOW 4): the website was moved with `@ CNAME bakery-site.example.net.` (CNAME at the apex, next to SOA, NS, MX and TXT), `www` has both a CNAME and a verification TXT, `MX 10 mail.example.com` lacks its trailing dot so it means `mail.example.com.example.com.`, the backup MX points at a CNAME, there are two `v=spf1` records (one with `?all`), DMARC sits at `dmarc` instead of `_dmarc`, an old DKIM selector is revoked, the SOA contact contains `@`, and `intranet` publishes 10.0.4.12. `examples/example.com.fixed.zone` fixes all of them and lints clean (exit 0).

## Rules

- Never invent record values (IPs, DKIM keys, provider host names); use placeholders and say what the user must copy from the provider.
- Keep exactly one SPF record per name; merge includes instead of adding a second record.
- Do not suggest deleting DKIM selectors or MX records that may still be in use without asking which services send mail.
- Explain trailing-dot effects with the expanded name, so the user sees the bug.
- Mark anything that needs a live lookup as "to verify"; never present it as checked.
- Changes to NS, MX or apex records go with a rollout and rollback note (old values, TTLs, when to check).
FILE:references/record-rules.md
# DNS record rules the linter applies

## Names and trailing dots
- In a zone file a name without a final dot is relative: `www` and `mail.example.com` both get the origin appended. `mail.example.com` in zone `example.com.` becomes `mail.example.com.example.com.` - the most common copy-paste bug. Write `mail.example.com.` or just `mail`.
- `@` is the origin (apex). A line starting with whitespace reuses the previous owner name.
- Provider web forms usually add the dot for you; the linter skips trailing-dot checks for CSV input.

## CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- A name with a CNAME may have no other data (only DNSSEC records). Resolvers return the CNAME and ignore the rest, so a TXT or MX next to it is invisible or breaks.
- The apex always has SOA and NS, so a CNAME at the apex is never valid. Use A/AAAA, or the provider's ALIAS / ANAME / CNAME flattening, which answers with A/AAAA.
- Only one CNAME per name. Chains (CNAME to CNAME) work but cost lookups; keep them short.
- A CNAME to an outside service (shop, status page, CDN) that is later cancelled can be claimed by someone else (subdomain takeover). List them and remove the ones no longer used.

## MX, NS, SRV targets (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- MX, NS and SRV must name a host with A/AAAA records, not a CNAME and not an IP address.
- Lower MX preference wins. Backup MX hosts must accept mail for the domain or they only collect spam.
- Null MX `MX 0 .` says the domain receives no mail. It must be the only MX.
- Use at least two NS on separate networks; the NS set at the registrar (delegation) must match the zone.

## SPF (RFC 7208)
- One `v=spf1` TXT record per name. Two records = permerror = SPF fails for all mail.
- Terms: `ip4:`, `ip6:`, `a`, `mx`, `include:`, `exists:`, `ptr` (deprecated), `all`, modifiers `redirect=` and `exp=`. Qualifiers: `+` pass (default), `-` fail, `~` softfail, `?` neutral.
- Max 10 terms that cause DNS lookups (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`), counted across nested includes. `ip4`/`ip6` cost nothing.
- End with `-all` (or `~all` while testing). `+all` lets anyone send as you; `?all` gives no signal. Terms after `all` are ignored.
- The SPF record type (99) is obsolete; publish TXT only.
- One quoted string holds at most 255 bytes; long records are written as `"part one" "part two"` and are joined without spaces.

## DMARC (RFC 7489)
- TXT at `_dmarc.<domain>` only. Must start with `v=DMARC1;` and include `p=none|quarantine|reject`. One record.
- Add `rua=mailto:` to get aggregate reports. Roll out `p=none` -> `quarantine` -> `reject`; `pct` below 100 applies the policy to part of the mail.
- Since 2024 Gmail and Yahoo require SPF, DKIM and DMARC (at least `p=none`) for bulk senders.

## DKIM (RFC 6376, RFC 8301, RFC 8463)
- Keys live at `<selector>._domainkey.<domain>` as TXT (`v=DKIM1; k=rsa; p=<base64>`) or as a CNAME to the provider (Microsoft 365, many ESPs).
- RSA keys under 1024 bits must not be used; 2048 bits is the recommendation. Ed25519 (`k=ed25519`) can be added alongside RSA.
- Empty `p=` means revoked. Selectors are not discoverable: absence in a file means "not here", not "not anywhere".

## TTL and SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL is 0..2147483647 s. Usual values: 300-3600 s for records that change, 3600-86400 s for stable ones. Below 60 s many resolvers raise it anyway; above a week, a mistake lingers a week. Lower the TTL a day before a planned change.
- `$TTL` sets the default. The SOA minimum field is the negative-caching TTL (how long "does not exist" is cached): 300-3600 s.
- The SOA contact (rname) is an email written with a dot: `hostmaster.example.com.` for hostmaster@example.com.
- Bump the SOA serial on every change (YYYYMMDDnn).

## Addresses and CAA
- A holds IPv4, AAAA holds IPv6. Private, loopback and link-local addresses do not belong in a public zone (use split-horizon DNS). Documentation ranges (192.0.2.0/24, 2001:db8::/32) are fine in examples.
- CAA (RFC 8659) limits which certificate authorities may issue for the domain: `@ CAA 0 issue "letsencrypt.org"`.
FILE:references/finding-codes.md
# Finding codes

| Code | Sev | Problem | Acceptable when | Usual fix |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | apex CNAME conflicts with SOA/NS, breaks mail | never | A/AAAA or provider ALIAS/flattening |
| CNAME_WITH_OTHER_DATA | HIGH | other records next to a CNAME are ignored | DNSSEC records only | move the TXT/MX elsewhere or use A/AAAA |
| MULTIPLE_CNAME | HIGH | two CNAMEs for one name | never | keep one |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | MX/NS/SRV target is an alias | never (some servers tolerate it, many do not) | point at the final A/AAAA host |
| TARGET_IS_IP | HIGH | MX/NS/CNAME value is an IP | never | host name with an A record |
| MISSING_TRAILING_DOT | HIGH | full name without dot gets the origin appended | never | add the dot or write it relative |
| OWNER_DOUBLED_ORIGIN | HIGH | owner `www.example.com` without dot | never | `www` or `www.example.com.` |
| SPF_MULTIPLE | HIGH | two v=spf1 records -> permerror | never | merge into one |
| SPF_PASS_ALL | HIGH | `+all`/`all` authorizes everyone | never | `-all` / `~all` |
| SPF_SYNTAX | HIGH | unknown term or bad ip4/ip6 -> permerror | never | fix the term |
| SPF_TOO_MANY_LOOKUPS | HIGH | more than 10 lookup terms | never | flatten, remove unused includes |
| TXT_STRING_TOO_LONG | HIGH | a quoted string over 255 bytes | provider UIs split for you (CSV input is not checked) | split into chunks |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 not at _dmarc | never | move to `_dmarc` |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | receivers ignore DMARC | never | one valid record |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | key cannot be found or parsed | never | copy again from the provider |
| DKIM_WEAK_KEY | HIGH | RSA key below 1024 bits | never | rotate to 2048 |
| SOA_RNAME_AT | HIGH | `@` in the SOA contact | never | `hostmaster.example.com.` |
| NULL_MX_WITH_OTHERS | HIGH | `MX 0 .` plus real MX | never | one or the other |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | value cannot be loaded | never | fix the value |
| SPF_MISSING | MEDIUM | MX but no SPF | never for a mail domain | one SPF record |
| DMARC_MISSING | MEDIUM | mail domain without DMARC | never | `v=DMARC1; p=none; rua=...` then tighten |
| DKIM_NOT_FOUND | MEDIUM | no selector in this file | selectors hosted elsewhere (check live) | publish the provider's selector |
| DKIM_REVOKED | MEDIUM | `p=` empty | intentionally retired selector (then remove it) | remove or publish the new key |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | weak or ignored policy | short test phase | end with `-all` / `~all` |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | looks like an outside name without dot | it really is a relative name | add the dot |
| DANGLING_TARGET | MEDIUM | in-zone target has no records | defined in another file or view | add or fix |
| MX_TARGET_NO_ADDRESS | MEDIUM | mail host has no A/AAAA | address comes from another zone | add A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | RFC 1918/loopback/link-local in public DNS | deliberately internal-only zone | split-horizon |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | below 60 s / above 7 days | short TTL during a planned cut-over; `_acme-challenge` | 300 s - 1 day |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | incomplete zone file | provider-managed NS/SOA (CSV input skips these) | add them |
| UNKNOWN_TYPE | MEDIUM | record type not recognized | new type the script does not know | check spelling |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | deprecated or close to the limit | - | replace / count nested includes live |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC not yet enforcing or not reporting | during rollout | tighten after reviewing reports |
| DKIM_1024_KEY | LOW | 1024-bit RSA | provider does not offer 2048 | rotate when possible |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | no-mail domain can be spoofed | - | null MX + `v=spf1 -all` + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | hygiene | - | see the fix column in the report |

Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. In CI, gate on HIGH and keep any `--ignore` list in the repo with a reason.

## Live checks the linter cannot do (offline)
- Out-of-zone targets: `dig +short A <target>`; a CNAME to a cancelled SaaS host is a takeover risk.
- Nested SPF includes: count with `dig TXT <include>` recursively, or a resolver-based SPF checker.
- DKIM selectors hosted by the mail provider: `dig TXT <selector>._domainkey.<domain>` (Google: `google`, Microsoft 365: `selector1`/`selector2` CNAMEs).
- Delegation: `dig NS <domain> @a.gtld-servers.net` (or the TLD's servers) must match the zone's NS set.
FILE:templates/dns-review.md
# DNS review: <domain>

**Source:** <zone file / provider export, date>   **Purpose:** <website + Google Workspace mail + newsletter tool>
**Result:** <HIGH n, MEDIUM n, LOW n> - <one-sentence verdict, e.g. "mail delivery is at risk until SPF is merged and MX is fixed">

## Fix now (HIGH)
| # | Record now | Problem | Replace with |
|---|---|---|---|
| 1 | `<@ CNAME bakery-site.example.net.>` | <CNAME at apex hides MX/TXT> | `<@ A 192.0.2.10>` (or ALIAS at the provider) |
| 2 | `<@ MX 10 mail.example.com>` | <no trailing dot -> mail.example.com.example.com.> | `<@ MX 10 mail.example.com.>` |

## Fix soon (MEDIUM)
- <add `_dmarc` TXT `v=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>`>

## Hygiene (LOW)
- <remove duplicate A for blog; add CAA>

## Mail authentication summary
| Check | Now | After the fix |
|---|---|---|
| SPF | <2 records, ?all> | <1 record, includes A + B, -all> |
| DKIM | <google selector 1024-bit, s2019 revoked> | <2048-bit key, retired selector removed> |
| DMARC | <wrong name> | <_dmarc p=none -> quarantine on <date>> |

## Verify live (not checked offline)
- `<dig +short CNAME shop.<domain>>` - <target still in use?>
- `<dig TXT google._domainkey.<domain>>` - <matches the provider console?>

## Rollout
1. <lower TTL of changed records to 300 s, wait one old TTL>
2. <apply changes, bump SOA serial to YYYYMMDDnn>
3. <verify with dig against the authoritative servers and a public resolver; send a test mail and read the Authentication-Results header>
4. <raise TTLs back to 3600 s; review DMARC reports after 1-2 weeks>

**Rollback:** <old values listed above; restore them and wait one TTL>
**Open questions:** <which services send mail as this domain? is backupmx still used?>
FILE:examples/example.com.zone
; example.com - zone exported before the move to a new DNS host (has mistakes on purpose)
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster@example.com. (
                2026103001 ; serial
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                172800 )   ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.

; website moved to a static host
@       IN CNAME bakery-site.example.net.
www  30 IN CNAME bakery-site.example.net.
www     IN TXT  "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com
blog    IN A    192.0.2.44
blog    IN A    192.0.2.44
legacy 1209600 IN A 192.0.2.45
intranet IN A   10.0.4.12

; mail
@       IN MX 10 mail.example.com
@       IN MX 20 backupmx
backupmx IN CNAME mx2.example.net.
@       IN TXT  "v=spf1 include:_spf.google.com ~all"
@       IN TXT  "v=spf1 include:sendgrid.net ?all"
dmarc   IN TXT  "v=DMARC1; p=none"
google._domainkey IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbNfaf4mo/6FvrWAv3WssXhXwsdrJxrAuhIVcYXdrMZp55PDpEjo63C/vOuaStp58YtZTnzQlm0V3rjtvOP1XgGAy3LWurXdRkT0zOGxx0AMgjZfD/8QDgjF6f4VW9zmNytbD3EgZXxeGZSBw8JtTdT/rOEygTgYL5+x5DftDSFQIDAQAB"
s2019._domainkey  IN TXT "v=DKIM1; k=rsa; p="
FILE:examples/example.com.fixed.zone
; example.com - corrected zone
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster.example.com. (
                2026103002 ; serial (bumped)
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                3600 )     ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.
@       IN CAA  0 issue "letsencrypt.org"

; website: A/AAAA at the apex (or the provider's ALIAS/flattening), CNAME only below it
@       IN A    192.0.2.10
@       IN AAAA 2001:db8::10
www     IN CNAME bakery-site.example.net.
_site-verification.www IN TXT "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com.
blog    IN A    192.0.2.44
legacy  IN A    192.0.2.45
; intranet moved to the internal (split-horizon) view

; mail
@       IN MX 10 mail.example.com.
@       IN MX 20 mx2.example.net.
mail    IN A    192.0.2.25
@       IN TXT  "v=spf1 include:_spf.google.com include:sendgrid.net -all"
_dmarc  IN TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
google._domainkey IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7ouq6bH4GbHRPASRa3m/LiSvOKSZ7L7KZqE231EHY1s8FN9criBQkJXXmxkwPERQSVSyRIC4LjSpxwPTe6LEVhg7nrswTX1ktBbXMUmkgj8CPKV+yWzOsDOMk7kjqghuNWXD/R5K/mT7xbTiCaXztegoQi+"
                           "kVXf0xuMmQYLxVthdjIz1h2PORFhtjzwKYbpaMKjq2BWKIaNtaGBxc2kaSLyPw8W65+A7XeLhtKT8zcteU72IpdmibNsHlkBR0Hb7rQgb+0LgNVqPvF/mm37PKFPQPrfWjjL/Fc1MQoCm8jO3kI0O5ssuP7KKO7ZjtT4ekeHytw23INuPh/Kw1LqzwIDAQAB" )
FILE:examples/lint-report.md
# DNS record lint: example.com.zone

Zone: example.com.   Format: zone   Records: 19   Findings: 19 (HIGH 7, MEDIUM 8, LOW 4)

| Sev | Code | Line | Name | Type | Problem | Fix |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | contact 'hostmaster@example.com.' contains '@'; in DNS it is written with a dot | write the contact as hostmaster.example.com. (first @ becomes a dot) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | SOA minimum 172800 s: a deleted-then-added name stays 'does not exist' in caches that long | set the SOA minimum (negative-caching TTL) to 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break | use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | name has a CNAME and also TXT; resolvers return only the CNAME | a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: extra query load, slower lookups, and many resolvers raise it anyway | use 300 s or more for normal records (lower only for a planned change) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' looks like a full host name but has no trailing dot, so it means shops.myshopify.com.example.com. | if this is an outside host name, add the trailing dot |
| LOW | DUPLICATE_RECORD | 19 | blog | A | same as line 18 | delete the duplicate |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 days): a wrong value stays cached that long | use 1 h to 1 day; above a week, mistakes take a week to undo |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 is a private/loopback address; outside users cannot reach it and it reveals internal layout | do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' has no trailing dot, so it means mail.example.com.example.com. | end fully qualified names with a dot (mail.example.com.) or write the relative form (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | MX target mail has no records in this zone | add the missing in-zone record or fix the target name |
| HIGH | MX_TO_CNAME | 25 | @ | MX | MX target backupmx is a CNAME (not allowed, RFC 2181 10.3) | point MX at a name with A/AAAA records (the CNAME's final target) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | 2 v=spf1 records at this name (lines 27, 28) | merge into ONE v=spf1 record; two records make every SPF check fail (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' is neutral: unauthorized senders are not marked | use ~all or -all; ?all gives receivers no signal |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | DMARC record is not at _dmarc.<domain>, so receivers never see it | DMARC is only read from _dmarc.<domain>; move the record there |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | RSA key is 1024 bits; 2048 is the current recommendation | plan rotation to a 2048-bit key |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= is empty: this selector is revoked and mail signed with it fails DKIM | empty p= revokes the key; remove the record if the selector is retired, or publish the new key |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders | add TXT at _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@<domain>" then move to quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | no CAA record: any public CA may issue certificates for this domain | optional hardening: CAA 0 issue "<your CA>" limits who may issue certificates |

## Out-of-zone targets to check live (no network calls were made)

| Target | Used by |
|---|---|
| bakery-site.example.net. | @ CNAME (line 14); www CNAME (line 15) |
| mx2.example.net. | backupmx CNAME (line 26) |
| ns1.example.net. | @ NS (line 10) |
| ns2.example.net. | @ NS (line 11) |
| shops.myshopify.com. | shop CNAME (line 17) |
FILE:scripts/dns_lint.py
#!/usr/bin/env python3
"""DNS zone record linter (Python 3.8+ standard library only, works offline).

Reads a BIND-style zone file or a CSV record list exported from a DNS provider and
reports common mistakes before they break web or mail delivery:
CNAME at the apex or next to other records, MX/NS pointing at a CNAME or an IP,
missing trailing dots (mail.example.com -> mail.example.com.example.com.), missing or
duplicate SPF, SPF syntax, +all/?all and the 10-lookup limit, DMARC and DKIM presence
and syntax, weak or revoked DKIM keys, TTL extremes, private addresses in a public
zone, SOA mistakes, duplicate records, and domains that send no mail but have no
"no mail" policy. Out-of-zone targets are listed for a separate live check.

Usage:
  python3 dns_lint.py example.com.zone [--origin example.com] [--json]
        [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE]
  python3 dns_lint.py records.csv --origin example.com      (CSV columns: name,type,value[,ttl][,priority])
  python3 dns_lint.py --self-test
Zone files: $ORIGIN, $TTL, @, relative names, blank owner (repeat previous), optional
TTL/class in either order, ( ) multi-line records, ; comments, quoted TXT strings.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, base64, csv, io, ipaddress, json, os, re, sys

SEVERITIES = ["LOW", "MEDIUM", "HIGH"]
CLASSES = {"IN", "CH", "HS"}
TYPES = {"A", "AAAA", "CNAME", "MX", "NS", "TXT", "SPF", "SOA", "SRV", "CAA", "PTR", "DNAME",
         "ALIAS", "ANAME", "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "HTTPS",
         "SVCB", "TLSA", "NAPTR", "SSHFP", "LOC", "HINFO", "CDS", "CDNSKEY"}
DNSSEC_TYPES = {"RRSIG", "NSEC", "NSEC3", "KEY"}
TARGET_TYPES = {"CNAME": -1, "MX": 1, "NS": 0, "PTR": 0, "DNAME": 0, "SRV": 3, "ALIAS": 0, "ANAME": 0}
COMMON_TLDS = set("com net org edu gov mil int io co dev app ai info biz me tv cloud xyz site online "
                  "shop store tech us uk de fr nl eu es it se no dk fi pl cz at ch be pt ie tr ru "
                  "jp cn kr in au nz ca br mx ar za sg hk tw".split())
INTERNAL_NETS = [ipaddress.ip_network(n) for n in (
    "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
    "0.0.0.0/8", "::1/128", "::/128", "fc00::/7", "fe80::/10")]  # documentation ranges (192.0.2.0/24, 2001:db8::/32) are allowed
SPF_LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")

FIXES = {
    "CNAME_AT_APEX": "use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record",
    "CNAME_WITH_OTHER_DATA": "a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA",
    "MULTIPLE_CNAME": "keep exactly one CNAME for the name",
    "MX_TO_CNAME": "point MX at a name with A/AAAA records (the CNAME's final target)",
    "NS_TO_CNAME": "point NS at the name server's real host name (A/AAAA)",
    "TARGET_IS_IP": "use a host name here and give that host an A/AAAA record",
    "MISSING_TRAILING_DOT": "end fully qualified names with a dot (mail.example.com.) or write the relative form (mail)",
    "MAYBE_MISSING_TRAILING_DOT": "if this is an outside host name, add the trailing dot",
    "OWNER_DOUBLED_ORIGIN": "end the owner name with a dot or write it relative (www)",
    "DANGLING_TARGET": "add the missing in-zone record or fix the target name",
    "CNAME_CHAIN": "point straight at the final name; chains add lookups and break when one link changes",
    "MX_TARGET_NO_ADDRESS": "add A/AAAA records for the mail host",
    "NULL_MX_WITH_OTHERS": "a null MX ('0 .') must be the only MX; remove it or the others",
    "SPF_MISSING": "publish one TXT \"v=spf1 ... -all\" (or ~all while testing) listing every service that sends as this domain",
    "SPF_MULTIPLE": "merge into ONE v=spf1 record; two records make every SPF check fail (permerror)",
    "SPF_TYPE99": "delete the SPF-type record; only the TXT record is used (RFC 7208)",
    "SPF_SYNTAX": "fix the term; one bad term makes the whole record a permerror",
    "SPF_PASS_ALL": "end with -all (or ~all while testing); +all lets anyone send as you",
    "SPF_NEUTRAL_ALL": "use ~all or -all; ?all gives receivers no signal",
    "SPF_NO_ALL": "end the record with -all or ~all (or redirect=)",
    "SPF_TERMS_AFTER_ALL": "move 'all' to the end; terms after it are ignored",
    "SPF_TOO_MANY_LOOKUPS": "flatten or remove includes, drop a/mx/ptr terms you do not need; nested includes count too",
    "SPF_LOOKUPS_NEAR_LIMIT": "count nested includes with a live checker; they add to this number",
    "SPF_PTR": "replace ptr with ip4/ip6 or include (ptr is deprecated and slow)",
    "TXT_STRING_TOO_LONG": "split into quoted chunks of at most 255 characters: \"part1\" \"part2\"",
    "DMARC_MISSING": "add TXT at _dmarc: \"v=DMARC1; p=none; rua=mailto:dmarc@<domain>\" then move to quarantine/reject",
    "DMARC_MULTIPLE": "keep one DMARC record; with two, receivers ignore DMARC entirely",
    "DMARC_SYNTAX": "start with v=DMARC1; and include p=none|quarantine|reject",
    "DMARC_WRONG_NAME": "DMARC is only read from _dmarc.<domain>; move the record there",
    "DMARC_MONITOR_ONLY": "after reviewing reports, move to p=quarantine, then p=reject",
    "DMARC_NO_RUA": "add rua=mailto:... so you get aggregate reports",
    "DMARC_PARTIAL_PCT": "raise pct to 100 once reports look clean",
    "DKIM_NOT_FOUND": "publish the DKIM selector record(s) your mail provider gives you (<selector>._domainkey)",
    "DKIM_WRONG_NAME": "DKIM keys live at <selector>._domainkey.<domain>",
    "DKIM_REVOKED": "empty p= revokes the key; remove the record if the selector is retired, or publish the new key",
    "DKIM_BAD_KEY": "copy the key again from the provider (base64, no spaces or quotes inside)",
    "DKIM_WEAK_KEY": "rotate to a 2048-bit RSA key (or add an ed25519 selector alongside RSA)",
    "DKIM_1024_KEY": "plan rotation to a 2048-bit key",
    "NO_MAIL_POLICY": "for a domain that never sends mail: MX 0 . , TXT \"v=spf1 -all\" and _dmarc \"v=DMARC1; p=reject\"",
    "NULL_MX_SPF_NOT_FAIL": "a no-mail domain should publish \"v=spf1 -all\"",
    "TTL_TOO_LOW": "use 300 s or more for normal records (lower only for a planned change)",
    "TTL_TOO_HIGH": "use 1 h to 1 day; above a week, mistakes take a week to undo",
    "TTL_INVALID": "TTL must be 0..2147483647 seconds",
    "NO_DEFAULT_TTL": "add $TTL at the top of the zone (e.g. $TTL 3600)",
    "SOA_MISSING": "a zone file needs exactly one SOA at the apex",
    "SOA_RNAME_AT": "write the contact as hostmaster.example.com. (first @ becomes a dot)",
    "SOA_NEGATIVE_TTL_HIGH": "set the SOA minimum (negative-caching TTL) to 300-3600 s",
    "NS_MISSING": "list the zone's name servers as NS records at the apex",
    "NS_SINGLE": "use at least two name servers on separate networks",
    "BAD_ADDRESS": "fix the address",
    "WRONG_ADDRESS_FAMILY": "A takes IPv4, AAAA takes IPv6",
    "PRIVATE_ADDRESS": "do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed)",
    "DUPLICATE_RECORD": "delete the duplicate",
    "CAA_MISSING": "optional hardening: CAA 0 issue \"<your CA>\" limits who may issue certificates",
    "UNKNOWN_TYPE": "check the record type spelling",
    "PARSE_ERROR": "fix the line syntax",
    "INCLUDE_NOT_FOLLOWED": "lint the included file separately",
}


class InputError(Exception):
    pass


class Rec:
    __slots__ = ("line", "name", "raw_name", "ttl", "type", "rdata", "raw")

    def __init__(self, line, name, raw_name, ttl, rtype, rdata, raw):
        self.line, self.name, self.raw_name, self.ttl = line, name, raw_name, ttl
        self.type, self.rdata, self.raw = rtype, rdata, raw


def parse_ttl(tok):
    t = tok.lower()
    if t.isdigit():
        return int(t)
    m = re.fullmatch(r"((\d+)[wdhms])+", t)
    if not m:
        return None
    mult = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
    return sum(int(n) * mult[u] for n, u in re.findall(r"(\d+)([wdhms])", t))


def tokenize(text):
    """Split one logical line into tokens; quoted strings keep their quotes."""
    toks, i, n = [], 0, len(text)
    while i < n:
        c = text[i]
        if c.isspace():
            i += 1
        elif c == '"':
            j, buf = i + 1, ['"']
            while j < n and text[j] != '"':
                if text[j] == "\\" and j + 1 < n:
                    buf.append(text[j:j + 2]); j += 2; continue
                buf.append(text[j]); j += 1
            if j >= n:
                raise InputError("unterminated quoted string")
            buf.append('"'); toks.append("".join(buf)); i = j + 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] != '"':
                j += 1
            toks.append(text[i:j]); i = j
    return toks


def strip_comment(line):
    out, inq, i = [], False, 0
    while i < len(line):
        c = line[i]
        if c == "\\" and i + 1 < len(line):
            out.append(line[i:i + 2]); i += 2; continue
        if c == '"':
            inq = not inq
        elif c == ";" and not inq:
            break
        out.append(c); i += 1
    return "".join(out)


def logical_lines(text):
    """Yield (line_no, starts_with_space, content) joining ( ... ) groups."""
    buf, start, depth, lead = [], 0, 0, False
    for no, line in enumerate(text.splitlines(), 1):
        body = strip_comment(line)
        if depth == 0:
            if not body.strip():
                continue
            start, lead, buf = no, body[:1].isspace(), []
        inq = False
        clean = []
        for c in body:
            if c == '"':
                inq = not inq
            if not inq and c in "()":
                depth += 1 if c == "(" else -1
                clean.append(" ")
                continue
            clean.append(c)
        buf.append("".join(clean))
        if depth <= 0:
            depth = 0
            yield start, lead, " ".join(buf)
    if depth > 0:
        raise InputError(f"line {start}: '(' is never closed")


def absname(raw, origin):
    if raw == "@":
        return origin
    if raw.endswith("."):
        return raw.lower()
    return (raw + "." + origin).lower() if origin != "." else raw.lower() + "."


def parse_zone(text, origin, findings):
    recs, last_owner, default_ttl, have_ttl_directive = [], None, None, False
    for no, lead, content in logical_lines(text):
        toks = tokenize(content)
        if not toks:
            continue
        if toks[0].upper() == "$ORIGIN":
            if len(toks) < 2:
                raise InputError(f"line {no}: $ORIGIN needs a name")
            origin = absname(toks[1], origin) if not toks[1].endswith(".") else toks[1].lower()
            continue
        if toks[0].upper() == "$TTL":
            v = parse_ttl(toks[1]) if len(toks) > 1 else None
            if v is None:
                raise InputError(f"line {no}: bad $TTL")
            default_ttl, have_ttl_directive = v, True
            continue
        if toks[0].upper() == "$INCLUDE":
            add(findings, "LOW", "INCLUDE_NOT_FOLLOWED", no, "", "", f"$INCLUDE {toks[1] if len(toks) > 1 else ''} was not read")
            continue
        if lead:
            if last_owner is None:
                raise InputError(f"line {no}: record has no owner name and there is no previous one")
            raw_owner = None
        else:
            raw_owner = toks.pop(0)
        ttl, rtype = None, None
        while toks:
            t = toks[0]
            if t.upper() in CLASSES:
                toks.pop(0); continue
            v = parse_ttl(t)
            if v is not None and ttl is None and not t.upper() in TYPES:
                ttl = v; toks.pop(0); continue
            break
        if not toks:
            add(findings, "HIGH", "PARSE_ERROR", no, raw_owner or "", "", f"no record type in '{content.strip()}'")
            continue
        rtype = toks.pop(0).upper()
        if rtype not in TYPES and not re.fullmatch(r"TYPE\d+", rtype):
            add(findings, "MEDIUM", "UNKNOWN_TYPE", no, raw_owner or "", rtype, f"unknown record type '{rtype}'")
        if raw_owner is not None:
            name = absname(raw_owner, origin)
            last_owner = (name, raw_owner)
        name, raw_name = last_owner
        recs.append(Rec(no, name, raw_name, ttl, rtype, toks, content.strip()))
    return recs, origin, default_ttl, have_ttl_directive


def parse_csv(text, origin, findings):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("CSV has no rows")
    cols = {c.lower().strip(): c for c in rows[0].keys() if c}
    for need in ("name", "type", "value"):
        if need not in cols:
            raise InputError(f"CSV needs columns name,type,value[,ttl][,priority] (missing '{need}')")
    recs = []
    base = origin.rstrip(".")
    for i, r in enumerate(rows, 2):
        g = lambda k: (r.get(cols[k]) or "").strip() if k in cols else ""
        raw = g("name") or "@"
        low = raw.lower().rstrip(".")
        if raw == "@" or low == base:
            name = origin
        elif low.endswith("." + base):
            name = low + "."
        else:
            name = low + "." + origin
        rtype = g("type").upper()
        value = g("value")
        ttl = parse_ttl(g("ttl")) if g("ttl") else None
        if rtype in ("TXT", "SPF"):
            v = value
            if not v.startswith('"'):
                v = '"' + v.replace('"', '\\"') + '"'
            toks = tokenize(v)
        else:
            toks = value.split()
            if rtype in TARGET_TYPES and toks:  # provider UIs show host names without the dot
                idx = TARGET_TYPES[rtype]
                if rtype == "MX" and len(toks) == 1:
                    toks = [g("priority") or "10"] + toks
                if rtype == "SRV" and len(toks) == 3 and g("priority"):
                    toks = [g("priority")] + toks
                k = len(toks) - 1 if idx == -1 else min(idx, len(toks) - 1)
                if not toks[k].endswith(".") and toks[k] != "@":
                    toks[k] += "."
        recs.append(Rec(i, name, raw, ttl, rtype, toks, ",".join(r.get(c) or "" for c in r)))
    return recs, origin


def add(findings, sev, code, line, name, rtype, problem):
    findings.append({"severity": sev, "code": code, "line": line, "name": name, "type": rtype,
                     "problem": problem, "fix": FIXES.get(code, "")})


def txt_strings(rec):
    out = []
    for t in rec.rdata:
        if t.startswith('"') and t.endswith('"'):
            out.append(re.sub(r"\\(.)", r"\1", t[1:-1]))
        else:
            out.append(t)
    return out


def short(name, origin):
    if name == origin:
        return "@"
    if name.endswith("." + origin):
        return name[: -len(origin) - 1]
    return name


def lint(text, origin=None, fmt=None, filename=""):
    findings = []
    if fmt is None:
        fmt = "csv" if filename.lower().endswith(".csv") or text.lstrip().lower().startswith("name,") else "zone"
    if origin is None:
        m = re.search(r"^\$ORIGIN\s+(\S+)", text, re.M | re.I)
        if m:
            origin = m.group(1)
        elif filename:
            b = os.path.basename(filename)
            for suf in (".zone", ".db", ".txt", ".csv"):
                if b.lower().endswith(suf):
                    b = b[: -len(suf)]
            for pre in ("db.",):
                if b.startswith(pre):
                    b = b[len(pre):]
            origin = b if "." in b else None
        if not origin:
            raise InputError("cannot tell the zone name: add $ORIGIN or pass --origin example.com")
    origin = origin.lower().rstrip(".") + "."
    if fmt == "csv":
        recs, origin = parse_csv(text, origin, findings)
        default_ttl, have_ttl = None, True
    else:
        recs, origin, default_ttl, have_ttl = parse_zone(text, origin, findings)
    base = origin.rstrip(".")
    by_name = {}
    for r in recs:
        by_name.setdefault(r.name, []).append(r)
    types_at = lambda n: {r.type for r in by_name.get(n, [])}
    in_zone = lambda n: n == origin or n.endswith("." + origin)
    externals = {}

    # SOA / NS / TTL
    soa = [r for r in recs if r.type == "SOA"]
    if fmt == "zone":
        if not soa:
            add(findings, "MEDIUM", "SOA_MISSING", 0, "@", "SOA", "no SOA record in the zone file")
        for r in soa:
            if len(r.rdata) >= 7:
                rname = r.rdata[1]
                if "@" in rname:
                    add(findings, "HIGH", "SOA_RNAME_AT", r.line, short(r.name, origin), "SOA", f"contact '{rname}' contains '@'; in DNS it is written with a dot")
                neg = parse_ttl(r.rdata[6])
                if neg is not None and neg > 86400:
                    add(findings, "LOW", "SOA_NEGATIVE_TTL_HIGH", r.line, short(r.name, origin), "SOA", f"SOA minimum {neg} s: a deleted-then-added name stays 'does not exist' in caches that long")
                if default_ttl is None and neg is not None:
                    default_ttl = neg
            else:
                add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), "SOA", "SOA needs 7 fields: mname rname serial refresh retry expire minimum")
        ns_apex = [r for r in by_name.get(origin, []) if r.type == "NS"]
        if not ns_apex:
            add(findings, "MEDIUM", "NS_MISSING", 0, "@", "NS", "no NS records at the apex")
        elif len(ns_apex) == 1:
            add(findings, "MEDIUM", "NS_SINGLE", ns_apex[0].line, "@", "NS", "only one name server: if it is down, the whole domain is down")
        if not have_ttl:
            add(findings, "LOW", "NO_DEFAULT_TTL", 0, "", "", "no $TTL directive; records without a TTL fall back to the SOA minimum or the server default")

    for r in recs:
        ttl = r.ttl if r.ttl is not None else default_ttl
        if ttl is None:
            continue
        nm = short(r.name, origin)
        if ttl > 2147483647:
            add(findings, "HIGH", "TTL_INVALID", r.line, nm, r.type, f"TTL {ttl} is above 2147483647")
        elif ttl > 604800:
            add(findings, "MEDIUM", "TTL_TOO_HIGH", r.line, nm, r.type, f"TTL {ttl} s ({ttl // 86400} days): a wrong value stays cached that long")
        elif ttl < 60 and not r.name.startswith("_acme-challenge."):
            add(findings, "MEDIUM", "TTL_TOO_LOW", r.line, nm, r.type, f"TTL {ttl} s: extra query load, slower lookups, and many resolvers raise it anyway")

    # owner names that repeat the origin
    for r in recs:
        if fmt == "zone" and r.raw_name not in (None, "@") and not r.raw_name.endswith(".") and \
                (r.raw_name.lower() == base or r.raw_name.lower().endswith("." + base)):
            add(findings, "HIGH", "OWNER_DOUBLED_ORIGIN", r.line, r.raw_name, r.type, f"owner '{r.raw_name}' has no trailing dot, so it becomes {r.name}")

    # CNAME rules
    for n, rs in by_name.items():
        cn = [r for r in rs if r.type == "CNAME"]
        if not cn:
            continue
        nm = short(n, origin)
        if n == origin:
            add(findings, "HIGH", "CNAME_AT_APEX", cn[0].line, nm, "CNAME", "CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break")
        others = sorted({r.type for r in rs if r.type not in ("CNAME",) and r.type not in DNSSEC_TYPES})
        if others and n != origin:
            add(findings, "HIGH", "CNAME_WITH_OTHER_DATA", cn[0].line, nm, "CNAME", f"name has a CNAME and also {', '.join(others)}; resolvers return only the CNAME")
        if len(cn) > 1:
            add(findings, "HIGH", "MULTIPLE_CNAME", cn[1].line, nm, "CNAME", f"{len(cn)} CNAME records for one name")

    # targets
    for r in recs:
        if r.type not in TARGET_TYPES or not r.rdata:
            continue
        idx = TARGET_TYPES[r.type]
        if r.type == "MX" and len(r.rdata) < 2 or r.type == "SRV" and len(r.rdata) < 4:
            add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), r.type, f"{r.type} needs " + ("preference and host" if r.type == "MX" else "priority weight port target"))
            continue
        raw = r.rdata[idx] if idx >= 0 else r.rdata[-1]
        nm = short(r.name, origin)
        if r.type == "MX" and raw == ".":
            continue
        try:
            ipaddress.ip_address(raw.rstrip("."))
            add(findings, "HIGH", "TARGET_IS_IP", r.line, nm, r.type, f"{r.type} target '{raw}' is an IP address; {r.type} must name a host")
            continue
        except ValueError:
            pass
        tgt = absname(raw, origin)
        if fmt == "zone" and raw != "@" and not raw.endswith("."):
            low = raw.lower()
            if low == base or low.endswith("." + base):
                add(findings, "HIGH", "MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."  # check the name that was meant
            elif "." in low and low.rsplit(".", 1)[1] in COMMON_TLDS:
                add(findings, "MEDIUM", "MAYBE_MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' looks like a full host name but has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."
        if in_zone(tgt):
            tt = types_at(tgt)
            if r.type in ("MX", "NS", "SRV") and "CNAME" in tt:
                code = {"MX": "MX_TO_CNAME", "NS": "NS_TO_CNAME", "SRV": "MX_TO_CNAME"}[r.type]
                add(findings, "HIGH", code, r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} is a CNAME (not allowed, RFC 2181 10.3)")
            elif r.type == "CNAME" and "CNAME" in tt:
                add(findings, "LOW", "CNAME_CHAIN", r.line, nm, r.type, f"CNAME to {short(tgt, origin)}, which is itself a CNAME")
            elif not tt:
                # wildcard or a deeper/glue name may exist elsewhere; only flag when no wildcard covers it
                wild = "*." + tgt.split(".", 1)[1] if "." in tgt else ""
                if wild not in by_name:
                    add(findings, "MEDIUM", "DANGLING_TARGET", r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} has no records in this zone")
            elif r.type == "MX" and not (tt & {"A", "AAAA"}):
                add(findings, "MEDIUM", "MX_TARGET_NO_ADDRESS", r.line, nm, r.type, f"mail host {short(tgt, origin)} has no A/AAAA record")
        else:
            externals.setdefault(tgt, []).append(f"{nm} {r.type} (line {r.line})")

    # addresses
    for r in recs:
        if r.type not in ("A", "AAAA") or not r.rdata:
            continue
        nm = short(r.name, origin)
        try:
            ip = ipaddress.ip_address(r.rdata[0])
        except ValueError:
            add(findings, "HIGH", "BAD_ADDRESS", r.line, nm, r.type, f"'{r.rdata[0]}' is not an IP address")
            continue
        if (r.type == "A") != (ip.version == 4):
            add(findings, "HIGH", "WRONG_ADDRESS_FAMILY", r.line, nm, r.type, f"{r.type} record holds an IPv{ip.version} address")
        elif any(ip in n for n in INTERNAL_NETS if n.version == ip.version):
            add(findings, "MEDIUM", "PRIVATE_ADDRESS", r.line, nm, r.type, f"{ip} is a private/loopback address; outside users cannot reach it and it reveals internal layout")

    # duplicates
    seen = {}
    for r in recs:
        key = (r.name, r.type, " ".join(x.lower() for x in r.rdata))
        if key in seen:
            add(findings, "LOW", "DUPLICATE_RECORD", r.line, short(r.name, origin), r.type, f"same as line {seen[key]}")
        else:
            seen[key] = r.line

    # TXT string length (zone files only: each quoted string max 255)
    if fmt == "zone":
        for r in recs:
            if r.type in ("TXT", "SPF"):
                for s in txt_strings(r):
                    if len(s.encode()) > 255:
                        add(findings, "HIGH", "TXT_STRING_TOO_LONG", r.line, short(r.name, origin), r.type, f"one quoted string is {len(s.encode())} bytes (max 255); the server rejects the zone or truncates it")
                        break

    # mail: MX, SPF, DMARC, DKIM
    txt_by_name = {}
    for r in recs:
        if r.type == "TXT":
            txt_by_name.setdefault(r.name, []).append((r, "".join(txt_strings(r))))
    for r in recs:
        if r.type == "SPF":
            add(findings, "LOW", "SPF_TYPE99", r.line, short(r.name, origin), "SPF", "SPF-type (99) record is obsolete and ignored")
    spf_at = {}
    for n, items in txt_by_name.items():
        for r, s in items:
            if re.match(r"(?i)v=spf1(\s|$)", s):
                spf_at.setdefault(n, []).append((r, s))
            if re.match(r"(?i)v=dmarc1", s) and not n.startswith("_dmarc."):
                add(findings, "HIGH", "DMARC_WRONG_NAME", r.line, short(n, origin), "TXT", "DMARC record is not at _dmarc.<domain>, so receivers never see it")
            if re.match(r"(?i)v=dkim1", s) and "._domainkey." not in "." + n:
                add(findings, "HIGH", "DKIM_WRONG_NAME", r.line, short(n, origin), "TXT", "DKIM key is not under <selector>._domainkey, so signatures cannot be verified")
    for n, items in spf_at.items():
        nm = short(n, origin)
        if len(items) > 1:
            add(findings, "HIGH", "SPF_MULTIPLE", items[1][0].line, nm, "TXT", f"{len(items)} v=spf1 records at this name (lines {', '.join(str(i[0].line) for i in items)})")
        for r, s in items:
            check_spf(findings, r, s, nm)

    mx_names = {}
    for r in recs:
        if r.type == "MX" and len(r.rdata) >= 2:
            mx_names.setdefault(r.name, []).append(r)
    for n, mxs in mx_names.items():
        nulls = [r for r in mxs if r.rdata[1] == "."]
        if nulls and len(mxs) > 1:
            add(findings, "HIGH", "NULL_MX_WITH_OTHERS", nulls[0].line, short(n, origin), "MX", "null MX ('0 .') next to other MX records")
    apex_mx = [r for r in mx_names.get(origin, []) if r.rdata[1] != "."]
    apex_null = any(r.rdata[1] == "." for r in mx_names.get(origin, []))
    apex_spf = spf_at.get(origin, [])
    spf_sends = any(not re.fullmatch(r"(?i)v=spf1\s+-all", s.strip()) for _, s in apex_spf)
    sends_mail = bool(apex_mx) or spf_sends
    for n, mxs in mx_names.items():
        if any(r.rdata[1] != "." for r in mxs) and n not in spf_at:
            add(findings, "MEDIUM", "SPF_MISSING", mxs[0].line, short(n, origin), "TXT", "name receives mail (MX) but publishes no SPF record")
    if apex_null and apex_spf and spf_sends:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", apex_spf[0][0].line, "@", "TXT", "null MX says no mail, but SPF still authorizes senders")
    if apex_null and not apex_spf:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", 0, "@", "TXT", "null MX but no \"v=spf1 -all\"")

    dm = [(r, s) for r, s in txt_by_name.get("_dmarc." + origin, []) if re.match(r"(?i)v=dmarc1", s.strip()) or "p=" in s.lower()]
    dkim_names = [n for n in by_name if n.endswith("._domainkey." + origin)]
    if sends_mail or apex_null:
        if not dm:
            add(findings, "MEDIUM", "DMARC_MISSING", 0, "_dmarc", "TXT", "no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders")
    if dm:
        if len(dm) > 1:
            add(findings, "HIGH", "DMARC_MULTIPLE", dm[1][0].line, "_dmarc", "TXT", f"{len(dm)} DMARC records")
        for r, s in dm:
            check_dmarc(findings, r, s)
    if sends_mail and not dkim_names:
        add(findings, "MEDIUM", "DKIM_NOT_FOUND", 0, "*._domainkey", "TXT", "no <selector>._domainkey record: if you send mail, it is probably unsigned (checked only names in this file)")
    for n in dkim_names:
        for r in by_name[n]:
            if r.type == "TXT":
                check_dkim(findings, r, "".join(txt_strings(r)), short(n, origin))
    if not sends_mail and not apex_null and not apex_spf:
        add(findings, "LOW", "NO_MAIL_POLICY", 0, "@", "MX", "no MX and no SPF: if this domain never sends mail, say so, or spammers can spoof it freely")

    has_web = any(r.type in ("A", "AAAA", "CNAME", "ALIAS", "ANAME") for r in recs)
    if has_web and "CAA" not in types_at(origin):
        add(findings, "LOW", "CAA_MISSING", 0, "@", "CAA", "no CAA record: any public CA may issue certificates for this domain")

    order = {s: i for i, s in enumerate(reversed(SEVERITIES))}
    findings.sort(key=lambda f: (f["line"] if f["line"] else 10 ** 6, order[f["severity"]], f["code"]))
    return {"origin": origin, "format": fmt, "records": len(recs), "findings": findings,
            "external_targets": [{"target": t, "used_by": u} for t, u in sorted(externals.items())]}


def check_spf(findings, r, s, nm):
    terms = s.split()[1:]
    lookups, seen_all = 0, None
    for i, t in enumerate(terms):
        q = t[0] if t[0] in "+-~?" else ""
        body = t[len(q):].lower()
        mech = re.split(r"[:=/]", body, maxsplit=1)[0]
        if seen_all is not None and not body.startswith("exp="):
            add(findings, "MEDIUM", "SPF_TERMS_AFTER_ALL", r.line, nm, "TXT", f"'{t}' comes after 'all' and is ignored")
            seen_all = None
        if mech == "all":
            if q in ("", "+"):
                add(findings, "HIGH", "SPF_PASS_ALL", r.line, nm, "TXT", f"'{t}' authorizes every server on the internet to send as this domain")
            elif q == "?":
                add(findings, "MEDIUM", "SPF_NEUTRAL_ALL", r.line, nm, "TXT", "'?all' is neutral: unauthorized senders are not marked")
            seen_all = i
            continue
        if mech in ("include", "exists"):
            if ":" not in body or not body.split(":", 1)[1]:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' needs a domain ({mech}:example.net)")
            lookups += 1
        elif mech in ("a", "mx"):
            lookups += 1
        elif mech == "ptr":
            lookups += 1
            add(findings, "LOW", "SPF_PTR", r.line, nm, "TXT", "'ptr' is deprecated (RFC 7208 5.5)")
        elif mech in ("ip4", "ip6"):
            val = body.split(":", 1)[1] if ":" in body else ""
            try:
                net = ipaddress.ip_network(val, strict=False)
                if (mech == "ip4") != (net.version == 4):
                    raise ValueError
            except ValueError:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' is not a valid {mech} address or range")
        elif mech == "redirect":
            lookups += 1
            if "=" not in body:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}': use redirect=domain")
        elif mech == "exp":
            pass
        else:
            add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"unknown SPF term '{t}' (typo? e.g. ipv4: for ip4:, include= for include:)")
    if not any(re.sub(r"^[+\-~?]", "", t).lower() == "all" for t in terms) and not any(t.lower().startswith("redirect=") for t in terms):
        add(findings, "MEDIUM", "SPF_NO_ALL", r.line, nm, "TXT", "record has no 'all' or redirect=, so unlisted senders get a neutral result")
    if lookups > 10:
        add(findings, "HIGH", "SPF_TOO_MANY_LOOKUPS", r.line, nm, "TXT", f"{lookups} DNS-lookup terms at the top level alone (limit 10, RFC 7208 4.6.4); receivers return permerror")
    elif lookups >= 8:
        add(findings, "LOW", "SPF_LOOKUPS_NEAR_LIMIT", r.line, nm, "TXT", f"{lookups} lookup terms before counting nested includes (limit 10)")


def check_dmarc(findings, r, s):
    tags = {}
    parts = [p.strip() for p in s.split(";") if p.strip()]
    if not parts or parts[0].replace(" ", "").upper() != "V=DMARC1":
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "record must start with 'v=DMARC1;'")
    for p in parts:
        k, _, v = p.partition("=")
        tags[k.strip().lower()] = v.strip()
    p = tags.get("p", "").lower()
    if p not in ("none", "quarantine", "reject"):
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "missing or invalid p= (none, quarantine or reject); receivers ignore the record")
        return
    if p == "none":
        add(findings, "LOW", "DMARC_MONITOR_ONLY", r.line, "_dmarc", "TXT", "p=none only monitors; spoofed mail is still delivered")
    if "rua" not in tags:
        add(findings, "LOW", "DMARC_NO_RUA", r.line, "_dmarc", "TXT", "no rua= address, so nobody sees the aggregate reports")
    if tags.get("pct", "100").isdigit() and int(tags.get("pct", "100")) < 100:
        add(findings, "LOW", "DMARC_PARTIAL_PCT", r.line, "_dmarc", "TXT", f"pct={tags['pct']}: the policy applies to only part of the failing mail")


def check_dkim(findings, r, s, nm):
    tags = {}
    for p in s.split(";"):
        k, _, v = p.strip().partition("=")
        if k:
            tags[k.strip().lower()] = re.sub(r"\s+", "", v)
    if "p" not in tags:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "no p= tag: not a DKIM key record")
        return
    if tags["p"] == "":
        add(findings, "MEDIUM", "DKIM_REVOKED", r.line, nm, "TXT", "p= is empty: this selector is revoked and mail signed with it fails DKIM")
        return
    try:
        raw = base64.b64decode(tags["p"], validate=True)
    except Exception:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "p= is not valid base64 (stray quote, space or cut-off copy?)")
        return
    if tags.get("k", "rsa").lower() == "ed25519":
        return
    bits = rsa_bits(raw)
    if bits is None:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", f"p= decodes to {len(raw)} bytes but is not an RSA public key (cut off?)")
    elif bits < 1024:
        add(findings, "HIGH", "DKIM_WEAK_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; keys under 1024 bits are rejected by receivers (RFC 8301)")
    elif bits < 2048:
        add(findings, "LOW", "DKIM_1024_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; 2048 is the current recommendation")


def der_read(b, i):
    tag = b[i]; i += 1
    ln = b[i]; i += 1
    if ln & 0x80:
        n = ln & 0x7F
        ln = int.from_bytes(b[i:i + n], "big"); i += n
    return tag, i, ln


def rsa_bits(raw):
    """Modulus size of an RSA SubjectPublicKeyInfo (or bare RSAPublicKey) in bits."""
    try:
        tag, i, ln = der_read(raw, 0)
        if tag != 0x30:
            return None
        tag, j, l2 = der_read(raw, i)
        if tag == 0x30:  # SubjectPublicKeyInfo: algorithm, then BIT STRING
            j += l2
            tag, j, l3 = der_read(raw, j)
            if tag != 0x03:
                return None
            j += 1  # unused-bits byte
            tag, j, l4 = der_read(raw, j)
            if tag != 0x30:
                return None
            tag, j, lm = der_read(raw, j)
        elif tag == 0x02:
            lm = l2
        else:
            return None
        if tag != 0x02 or j + lm > len(raw):
            return None
        mod = raw[j:j + lm].lstrip(b"\x00")
        return len(mod) * 8 if mod else None
    except IndexError:
        return None


def render(res, title, min_sev="LOW", ignore=()):
    keep = [f for f in res["findings"] if SEVERITIES.index(f["severity"]) >= SEVERITIES.index(min_sev) and f["code"] not in ignore]
    cnt = {s: sum(1 for f in keep if f["severity"] == s) for s in SEVERITIES}
    out = [f"# DNS record lint: {title}", "",
           f"Zone: {res['origin']}   Format: {res['format']}   Records: {res['records']}   Findings: {len(keep)} (HIGH {cnt['HIGH']}, MEDIUM {cnt['MEDIUM']}, LOW {cnt['LOW']})", ""]
    if keep:
        out += ["| Sev | Code | Line | Name | Type | Problem | Fix |", "|---|---|---|---|---|---|---|"]
        for f in keep:
            out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['name'] or '-'} | {f['type'] or '-'} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    if res["external_targets"]:
        out += ["", "## Out-of-zone targets to check live (no network calls were made)", "",
                "| Target | Used by |", "|---|---|"]
        for e in res["external_targets"]:
            out.append(f"| {e['target']} | {'; '.join(e['used_by'])} |")
    return "\n".join(out) + "\n", keep


def self_test():
    Z = "$ORIGIN example.com.\n$TTL 3600\n@ IN SOA ns1 hostmaster 1 7200 900 1209600 300\n@ NS ns1\n@ NS ns2.example.net.\nns1 A 192.0.2.53\n"
    MAIL = '@ MX 10 mail\nmail A 192.0.2.25\n@ TXT "v=spf1 mx -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\ns1._domainkey CNAME s1.dkim.example.net.\n@ CAA 0 issue "letsencrypt.org"\n'
    k1024 = base64.b64encode(bytes.fromhex("30819f300d06092a864886f70d010101050003818d0030818902818100") + b"\xc1" + bytes(127) + bytes.fromhex("0203010001")).decode()
    k512 = base64.b64encode(bytes.fromhex("305c300d06092a864886f70d0101010500034b003048024100") + b"\xc1" + bytes(63) + bytes.fromhex("0203010001")).decode()

    def codes(text, **kw):
        return [f["code"] for f in lint(text, **kw)["findings"]]

    cases = [
        ("clean zone", Z + MAIL, lambda c: c == []),
        ("cname at apex", Z + MAIL + "@ CNAME shop.example.net.\n", lambda c: "CNAME_AT_APEX" in c),
        ("cname with other data", Z + MAIL + "www CNAME web.example.net.\nwww TXT \"x\"\n", lambda c: "CNAME_WITH_OTHER_DATA" in c),
        ("multiple cname", Z + MAIL + "www CNAME a.example.net.\nwww CNAME b.example.net.\n", lambda c: "MULTIPLE_CNAME" in c),
        ("mx to cname", Z + MAIL.replace("mail A 192.0.2.25", "mail CNAME mx.example.net."), lambda c: "MX_TO_CNAME" in c),
        ("ns to cname", Z.replace("ns1 A 192.0.2.53", "ns1 CNAME dns.example.net.") + MAIL, lambda c: "NS_TO_CNAME" in c),
        ("mx is ip", Z + MAIL.replace("MX 10 mail", "MX 10 192.0.2.25"), lambda c: "TARGET_IS_IP" in c),
        ("missing trailing dot", Z + MAIL + "www CNAME web.example.com\nweb A 192.0.2.80\n", lambda c: "MISSING_TRAILING_DOT" in c),
        ("maybe missing dot (outside name)", Z + MAIL + "shop CNAME shops.myshopify.com\n", lambda c: "MAYBE_MISSING_TRAILING_DOT" in c),
        ("owner doubled origin", Z + MAIL + "www.example.com A 192.0.2.80\n", lambda c: "OWNER_DOUBLED_ORIGIN" in c),
        ("relative target ok", Z + MAIL + "www CNAME web\nweb A 192.0.2.80\n", lambda c: c == []),
        ("dangling target", Z + MAIL + "www CNAME web\n", lambda c: "DANGLING_TARGET" in c),
        ("cname chain", Z + MAIL + "www CNAME web\nweb CNAME edge.example.net.\n", lambda c: "CNAME_CHAIN" in c),
        ("mx target no address", Z + MAIL.replace("mail A 192.0.2.25", "mail TXT \"x\""), lambda c: "MX_TARGET_NO_ADDRESS" in c),
        ("spf missing", Z + MAIL.replace('@ TXT "v=spf1 mx -all"\n', ""), lambda c: "SPF_MISSING" in c),
        ("spf multiple", Z + MAIL + '@ TXT "v=spf1 include:_spf.google.com ~all"\n', lambda c: "SPF_MULTIPLE" in c),
        ("spf +all", Z + MAIL.replace("mx -all", "mx +all"), lambda c: "SPF_PASS_ALL" in c),
        ("spf ?all", Z + MAIL.replace("mx -all", "mx ?all"), lambda c: "SPF_NEUTRAL_ALL" in c),
        ("spf no all", Z + MAIL.replace("mx -all", "mx"), lambda c: "SPF_NO_ALL" in c),
        ("spf terms after all", Z + MAIL.replace("mx -all", "-all mx"), lambda c: "SPF_TERMS_AFTER_ALL" in c),
        ("spf syntax typo", Z + MAIL.replace("mx -all", "ipv4:192.0.2.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf bad ip4", Z + MAIL.replace("mx -all", "ip4:300.1.1.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf too many lookups", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(11)) + " -all"), lambda c: "SPF_TOO_MANY_LOOKUPS" in c),
        ("spf near limit", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(8)) + " -all"), lambda c: "SPF_LOOKUPS_NEAR_LIMIT" in c and "SPF_TOO_MANY_LOOKUPS" not in c),
        ("spf ptr", Z + MAIL.replace("mx -all", "ptr -all"), lambda c: "SPF_PTR" in c),
        ("spf type99", Z + MAIL + '@ SPF "v=spf1 mx -all"\n', lambda c: "SPF_TYPE99" in c),
        ("split txt strings joined", Z + MAIL.replace('"v=spf1 mx -all"', '"v=spf1 mx " "-all"'), lambda c: c == []),
        ("txt string too long", Z + MAIL + '@ TXT "' + "a" * 300 + '"\n', lambda c: "TXT_STRING_TOO_LONG" in c),
        ("dmarc missing", Z + MAIL.replace('_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n', ""), lambda c: "DMARC_MISSING" in c),
        ("dmarc wrong name", Z + MAIL.replace("_dmarc TXT", "dmarc TXT"), lambda c: "DMARC_WRONG_NAME" in c and "DMARC_MISSING" in c),
        ("dmarc multiple", Z + MAIL + '_dmarc TXT "v=DMARC1; p=none"\n', lambda c: "DMARC_MULTIPLE" in c),
        ("dmarc no p", Z + MAIL.replace("p=reject; ", ""), lambda c: "DMARC_SYNTAX" in c),
        ("dmarc none + no rua + pct", Z + MAIL.replace("p=reject; rua=mailto:d@example.com", "p=none; pct=50"), lambda c: {"DMARC_MONITOR_ONLY", "DMARC_NO_RUA", "DMARC_PARTIAL_PCT"} <= set(c)),
        ("dkim not found", Z + MAIL.replace("s1._domainkey CNAME s1.dkim.example.net.\n", ""), lambda c: "DKIM_NOT_FOUND" in c),
        ("dkim revoked", Z + MAIL + 's2._domainkey TXT "v=DKIM1; k=rsa; p="\n', lambda c: "DKIM_REVOKED" in c),
        ("dkim bad base64", Z + MAIL + 's2._domainkey TXT "v=DKIM1; p=MIGf!!"\n', lambda c: "DKIM_BAD_KEY" in c),
        ("dkim 512-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k512}"\n', lambda c: "DKIM_WEAK_KEY" in c),
        ("dkim 1024-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k1024}"\n', lambda c: "DKIM_1024_KEY" in c),
        ("dkim wrong name", Z + MAIL + f's2.domainkey TXT "v=DKIM1; p={k1024}"\n', lambda c: "DKIM_WRONG_NAME" in c),
        ("null mx + spf -all, no mail checks", Z + '@ MX 0 .\n@ TXT "v=spf1 -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n@ CAA 0 issue "letsencrypt.org"\n', lambda c: c == []),
        ("null mx with others", Z + MAIL + "@ MX 0 .\n", lambda c: "NULL_MX_WITH_OTHERS" in c),
        ("no mail policy", Z + '@ CAA 0 issue "letsencrypt.org"\n', lambda c: "NO_MAIL_POLICY" in c),
        ("ttl too low", Z + MAIL + "www 30 A 192.0.2.80\n", lambda c: "TTL_TOO_LOW" in c),
        ("acme ttl low ok", Z + MAIL + '_acme-challenge 30 TXT "token"\n', lambda c: c == []),
        ("ttl too high (units)", Z + MAIL + "www 2w A 192.0.2.80\n", lambda c: "TTL_TOO_HIGH" in c),
        ("no $TTL", Z.replace("$TTL 3600\n", "") + MAIL, lambda c: "NO_DEFAULT_TTL" in c),
        ("soa rname with @", Z.replace("hostmaster", "hostmaster@example.com.") + MAIL, lambda c: "SOA_RNAME_AT" in c),
        ("soa multi-line with comments", Z.replace("@ IN SOA ns1 hostmaster 1 7200 900 1209600 300", "@ IN SOA ns1 hostmaster (\n 2026103001 ; serial\n 7200 900 1209600\n 300 ) ; min") + MAIL, lambda c: c == []),
        ("single ns", Z.replace("@ NS ns2.example.net.\n", "") + MAIL, lambda c: "NS_SINGLE" in c),
        ("bad address", Z + MAIL + "www A 192.0.2.300\n", lambda c: "BAD_ADDRESS" in c),
        ("wrong family", Z + MAIL + "www AAAA 192.0.2.80\n", lambda c: "WRONG_ADDRESS_FAMILY" in c),
        ("private address", Z + MAIL + "intranet A 10.0.0.5\n", lambda c: "PRIVATE_ADDRESS" in c),
        ("duplicate record", Z + MAIL + "www A 192.0.2.80\nwww A 192.0.2.80\n", lambda c: "DUPLICATE_RECORD" in c),
        ("blank owner continues", Z + MAIL + "www A 192.0.2.80\n    AAAA 2001:db8::80\n", lambda c: c == []),
        ("semicolon inside quotes kept", Z + MAIL.replace('"v=DMARC1; p=reject; rua=mailto:d@example.com"', '"v=DMARC1; p=reject; rua=mailto:d@example.com" ; comment'), lambda c: c == []),
        ("caa missing", Z + MAIL.replace('@ CAA 0 issue "letsencrypt.org"\n', ""), lambda c: c == ["CAA_MISSING"]),
        ("csv input", None, None),
        ("unterminated quote is input error", None, None),
    ]
    passed = 0
    for name, text, check in cases:
        try:
            if name == "csv input":
                csvt = ("name,type,value,ttl,priority\n@,MX,mail.example.com,3600,10\nmail,A,192.0.2.25,3600,\n"
                        "@,TXT,v=spf1 include:_spf.google.com ~all,3600,\n@,TXT,v=spf1 mx -all,3600,\n"
                        "www.example.com,CNAME,example.com,300,\n@,CNAME,shop.example.net,300,\n")
                res = lint(csvt, origin="example.com", fmt="csv")
                c = [f["code"] for f in res["findings"]]
                ok = {"SPF_MULTIPLE", "CNAME_AT_APEX", "DMARC_MISSING", "DKIM_NOT_FOUND"} <= set(c) and "MISSING_TRAILING_DOT" not in c and "MX_TO_CNAME" not in c
            elif name == "unterminated quote is input error":
                try:
                    lint(Z + '@ TXT "v=spf1 -all\n')
                    ok, c = False, "no error"
                except InputError as e:
                    ok, c = True, str(e)
            else:
                c = codes(text)
                ok = check(c)
        except Exception as e:  # noqa
            ok, c = False, f"exception {e!r}"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  got {c}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Lint a DNS zone file or record list (offline)")
    ap.add_argument("file", nargs="?")
    ap.add_argument("--origin", help="zone name, e.g. example.com (default: $ORIGIN or the file name)")
    ap.add_argument("--format", choices=["zone", "csv"], help="input format (default: by extension/content)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--min-severity", default="LOW", choices=SEVERITIES)
    ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("give a zone file or CSV, or --self-test")
    try:
        with open(a.file, encoding="utf-8") as fh:
            text = fh.read()
        res = lint(text, origin=a.origin, fmt=a.format, filename=a.file)
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    ignore = {c.strip().upper() for c in a.ignore.split(",") if c.strip()}
    md, keep = render(res, os.path.basename(a.file), a.min_severity, ignore)
    if a.json:
        print(json.dumps({**res, "findings": keep}, indent=2))
    else:
        sys.stdout.write(md)
    return 1 if any(f["severity"] == "HIGH" for f in keep) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonDNS+2
F@f
0
Houseplant Watering Schedule Planner
Skill

Builds a houseplant watering check calendar from a list of plants with pot size and material, light, drainage and last watering date. Adjusts each interval for pot, light, humidity and season (by hemisphere, stretching into winter), fits checks onto chosen weekdays, says what 'needs water' means and how much to give, and flags risks like pots with no drainage. Includes a stdlib Python planner with .ics export and --self-test.

---
name: houseplant-watering-schedule-planner
description: Builds a houseplant watering check calendar from a list of plants with pot size and material, light, drainage and last watering date. Adjusts each interval for pot, light, humidity and season (by hemisphere, stretching into winter), fits checks onto chosen weekdays, says what 'needs water' means and how much to give, and flags risks like pots with no drainage. Includes a stdlib Python planner with .ics export and --self-test.
---

# Houseplant Watering Schedule Planner

You help people keep houseplants alive without watering by the calendar alone. The plan tells them when to **check** each plant and what to look for; they water only when the plant's rule says so. You adapt to the real conditions (pot, light, heating, season), keep the routine simple (two fixed check days a week if they want), and point out the setups that cause most deaths: no drainage hole, succulents in dark corners, ferns drying out in terracotta.

## Files in this skill

- `scripts/watering_planner.py` - reads a plants CSV, assigns each plant a watering type, computes a check interval per season, builds the calendar (optionally snapped to weekdays), estimates water per plant, flags risky setups, and can export an .ics file (Python 3.8+ standard library only)
- `references/plant-water-profiles.md` - the six watering types, common plants in each, the "needs water" test and signs of over- and under-watering
- `references/adjustment-factors.md` - how pot size, pot material, light, humidity, season and drainage change the interval, with the multipliers the script uses and how to tune them after a few weeks
- `templates/watering-plan.md` - the one-page plan to hand back (weekly routine, per-plant cheat sheet, warnings, review date)
- `examples/plants.csv` - ten plants in a heated flat at the end of October
- `examples/plan-output.md` - the real output of the planner for that list

## Workflow

### 1. Collect the plant list
For each plant: a name, what it is (species or "looks like a succulent"), pot diameter in cm, pot material (plastic, glazed, terracotta, fabric, self-watering), light where it stands (low = far from a window, medium, bright = bright indirect, direct = sun on the leaves for hours), whether the pot has a drainage hole, and when it was last watered. Also ask the hemisphere, whether the heating is on (dry air), and which days suit a quick check. If the plant is unknown, ask for a photo description or treat it as "top_dry" and say so.

### 2. Run the planner
```
python3 scripts/watering_planner.py examples/plants.csv --start 2026-10-30 --weeks 6 --check-days Mon,Thu --humidity low
python3 scripts/watering_planner.py plants.csv --hemisphere south --weeks 8 --ics plants.ics
python3 scripts/watering_planner.py plants.csv --json
python3 scripts/watering_planner.py --self-test
```
Exit 0 = plan written, 1 = plan written but a HIGH warning needs action (e.g. no drainage), 2 = bad input (unknown material or light, pot size in mm, bad date). Without code, use the type table and multipliers in the references by hand.

### 3. Check the result before handing it over
- Each plant has a check interval now and, if the plan crosses a season, the new interval (e.g. "7 d (winter: 9 d)").
- Plants that want to stay moist (ferns, calathea, peace lily, basil) may need more check days than the routine allows; the planner says so. Suggest a third check day, a plastic pot, or self-watering only for these.
- "Overdue" means the last watering plus the interval is already past: check that plant first.
- Read the warnings and turn HIGH ones into the first action of the plan.

### 4. Deliver
Fill `templates/watering-plan.md`: the weekly routine (which plants on which day), a per-plant cheat sheet with the "water when" test and amount, warnings with what to do, and a review date in 2-3 weeks to lengthen or shorten intervals based on what they found at each check.

## Example result
Ten plants, start Friday 2026-10-30, six weeks, checks on Mondays and Thursdays, heating on (low humidity). Ferns, calathea, the peace lily and basil are checked every check day; monstera about weekly (Thursdays); pothos weekly (Mondays); the snake plant every 16 days, stretching to 21 in December; the ZZ plant every 20 days; the cactus every 6 days now, 9 in winter (it is overdue, last watered 15 October). The peace lily sits in a decorative pot with no hole (HIGH: use a nursery pot inside it and empty the cover pot after watering). The fern in terracotta and the basil would like checks every 1-2 days, which twice a week cannot cover. See `examples/plan-output.md`.

## Rules

- Never prescribe watering on a fixed schedule without a check; always pair each check with the plant's "water when" test.
- Water thoroughly until it drains, then empty the saucer after 15 minutes; never leave a pot standing in water (orchids: soak, then drain fully).
- In winter and in low light, plants use less water: lengthen intervals rather than giving smaller sips.
- Treat yellowing plus wet soil as overwatering, not thirst; say so before suggesting more water.
- Do not guess a species from a vague name; ask or state the type you assumed.
- Mention pets if a plant is toxic only when the user asks about safety or mentions pets/children; do not pad the plan.
FILE:references/plant-water-profiles.md
# Watering types

| Type | Typical plants | Water when | Base check interval* | Winter slow-down |
|---|---|---|---|---|
| succulent | cactus, aloe, echeveria, jade, haworthia, string of pearls | soil dry all the way down: a wooden chopstick pushed to the bottom comes out clean and dry; pot feels light | 10 days | x2.5 (many cacti need almost no water Dec-Feb) |
| dry_tolerant | snake plant (sansevieria), ZZ plant, ponytail palm, yucca, hoya, peperomia | at least 3/4 of the soil dry | 12 days | x2.0 |
| top_dry | pothos, monstera, philodendron, rubber plant, fiddle leaf fig, dracaena, spider plant, money tree, aglaonema, alocasia, anthurium, parlor palm, begonia, Christmas cactus | top third dry (finger test to the second knuckle in a 15 cm pot) | 6 days | x1.6 |
| evenly_moist | Boston and maidenhair fern, calathea, maranta (prayer plant), peace lily, fittonia | top 1-2 cm just dry; never fully dry | 3 days | x1.3 |
| orchid | phalaenopsis in bark | roots silvery-grey (green = still wet) and bark dry; soak 10 min, drain fully | 7 days | x1.4 |
| herb | basil, mint, parsley on a windowsill | top 1-2 cm dry | 2 days | x1.2 |

*Base = spring, 15 cm plastic pot with drainage, medium light, normal humidity. Autumn is halfway between spring and winter; summer is x0.85. See `adjustment-factors.md`.

## How to check (30 seconds per plant)
1. **Finger test:** push a finger into the soil to the depth in the rule. Dry and crumbly = dry; cool and sticking = moist.
2. **Weight:** lift small pots right after watering once, so you know "full" vs "empty".
3. **Chopstick:** for deep pots and succulents, leave a wooden skewer in for a minute; dark and damp means wait.
4. **Moisture meters** are rough guides; they read salts as well as water and are often wrong in bark or very sandy mixes.

## Signs
| Overwatering (more common) | Underwatering |
|---|---|
| yellow lower leaves with soil still wet, soft or black stem base, mushy roots, fungus gnats, sour smell | crisp brown edges, drooping that recovers within hours of watering, soil pulling away from the pot edge, very light pot |
| Fix: stop watering, check roots, repot in dry mix if rotten, lengthen the interval | Fix: soak thoroughly (bottom-water 20-30 min for hydrophobic soil), shorten the interval a little |

Drooping with **wet** soil means roots are damaged: do not add water.

## Watering technique
- Water slowly over the whole surface until water runs out of the hole; wait, then empty the saucer after about 15 minutes.
- Room-temperature water. Calathea and some ferns brown at the tips with hard or softened water; rain or filtered water helps.
- Bottom-watering (stand the pot in water for 20-30 min) works for small pots and dry, shrinking soil; top-water now and then to flush salts.
- Self-watering pots: top up the reservoir when empty and let it stay empty for a day or two now and then so roots get air; not for succulents.
FILE:references/adjustment-factors.md
# Adjustment factors

Check interval = base days (type) x pot size x pot material x light x humidity x season, rounded, limited to 1..45 days. These are the multipliers in `scripts/watering_planner.py`.

| Factor | Value | Multiplier | Why |
|---|---|---|---|
| Pot diameter | under 10 cm | 0.6 | little soil, dries in days |
| | 10-13 cm | 0.8 | |
| | 14-19 cm | 1.0 | reference |
| | 20-27 cm | 1.3 | more soil holds water longer |
| | 28 cm and up | 1.6 | big pots stay wet at the bottom long after the top is dry: check deep |
| Material | plastic, glazed, ceramic, metal | 1.0 | walls do not breathe |
| | terracotta / unglazed clay | 0.75 | walls wick and evaporate water |
| | fabric grow bag | 0.7 | dries from all sides |
| | self-watering | 1.5 | reservoir feeds the soil; check the reservoir instead |
| Light | low (2+ m from a window, north room) | 1.4 | little growth, little use |
| | medium | 1.0 | |
| | bright indirect | 0.85 | |
| | direct sun on the leaves | 0.7 | |
| Humidity | low (heating on, below ~35 %) | 0.85 | soil and leaves lose water faster |
| | normal | 1.0 | |
| | high (bathroom, humidifier) | 1.15 | |
| Season | spring | 1.0 | |
| | summer | 0.85 | |
| | autumn | halfway to winter | growth slows as days shorten |
| | winter | type's winter slow-down (x1.2 to x2.5) | |

Seasons: northern hemisphere Dec-Feb winter, Mar-May spring, Jun-Aug summer, Sep-Nov autumn; southern hemisphere shifted by six months. Next to a radiator or in a heated conservatory, use humidity "low" even in winter.

## Drainage
No hole is the single biggest cause of root rot. Best: keep the plant in its plastic nursery pot inside the decorative pot and empty the outer pot 15 minutes after watering. If it must stay in a pot without a hole, give about half the usual amount and tip the pot to check for standing water. The planner flags this as HIGH.

## Amount
About 15-25 % of the pot volume, until water drains out of the hole. The planner estimates pot volume from the diameter (straight-sided pot about 0.9x as tall as wide), so a 15 cm pot gets about 450 ml, a 24 cm pot about 1.75 l. Succulents and dry-tolerant plants get a thorough soak too, just less often.

## Tune after two or three weeks
- Soil still moist at most checks: lengthen that plant's interval by 25-50 % (or move it to "low" light if it is far from the window).
- Soil bone-dry at every check and the plant droops: shorten by 25 %.
- Write the changes into the CSV (e.g. switch the type or light) and re-run, rather than remembering exceptions.
- Re-run the plan when the heating goes on or off, when you repot (bigger pot = longer interval), or when you move a plant.

## Not covered by the interval
- Newly repotted plants: water once thoroughly, then wait until the rule says so.
- Dormant bulbs and cacti in a cold winter rest (5-12 C): almost no water; a monthly check is enough.
- Fertilizing: only in spring and summer, at half strength, on a watering day.
FILE:templates/watering-plan.md
# Plant care plan: <home / room>

**Period:** <2026-10-30 to 2026-12-10>   **Check days:** <Mon and Thu, 5-10 minutes>
**Conditions:** <northern hemisphere, heating on (dry air)>   **Review on:** <2026-11-16>

## Do first
- <Peace lily: no drainage hole - move it into a plastic nursery pot inside the cover pot; empty the cover pot 15 min after watering>
- <Spike (cactus): overdue - check on Monday>

## Weekly routine
| Day | Check these plants |
|---|---|
| <Mon> | <ferns, calathea, peace lily, basil, orchid, pothos, cactus (every 2nd Mon in December)> |
| <Thu> | <ferns, calathea, peace lily, basil, orchid, monstera; snake plant every 2-3 weeks> |

## Cheat sheet
| Plant | Water when | About | Notes |
|---|---|---|---|
| <Monty (monstera)> | <top third dry> | <~1.75 l until it drains> | <bright east window> |
| <Sansa (snake plant)> | <3/4 dry> | <~450 ml> | <December: every 3 weeks> |

## How to check
- Finger in the soil to the depth in the "water when" column; water only if dry to that depth.
- Water until it drains, empty the saucer after 15 minutes.
- Wet soil + yellow leaves = too much water: skip and tell me.

## Warnings
- <Fernando (Boston fern) in terracotta dries in 2 days with the heating on: add a Saturday check or move it to a plastic pot>
- <Basil wants daily checks on the sunny sill; bottom-water when you pass by>

## At the review
For each plant, note whether the soil was usually still moist (lengthen) or bone dry (shorten) at checks; I will update the plan.
FILE:examples/plants.csv
name,plant,pot_cm,pot_material,light,drainage,last_watered,notes
Monty,monstera,24,plastic,bright,yes,2026-10-28,living room by the east window
Pothos shelf,pothos,14,glazed,medium,yes,2026-10-27,trailing from the bookshelf
Sansa,snake plant,17,terracotta,low,yes,2026-10-20,hallway corner
Zed,zz plant,20,plastic,medium,yes,,new from the shop
Fernando,boston fern,20,terracotta,bright,yes,2026-10-29,bathroom shelf
Cal,calathea,15,plastic,medium,yes,2026-10-28,bedroom
Spike,cactus,10,terracotta,direct,yes,2026-10-15,south windowsill
Peace lily,peace lily,18,glazed,low,no,2026-10-26,decorative pot with no hole
Orchid,phalaenopsis,12,plastic,bright,yes,2026-10-25,kitchen window
Basil,basil,13,terracotta,direct,yes,2026-10-29,kitchen windowsill
FILE:examples/plan-output.md
# Houseplant watering plan: 2026-10-30 to 2026-12-10

Hemisphere: north   Season at start: autumn   Humidity: low   Check days: Mon, Thu   Plants: 10

A check is not a watering: feel the soil and water only when the rule says so.

## Plants

| Plant | Type | Pot | Light | Check every | Water when | About | Checks |
|---|---|---|---|---|---|---|---|
| Monty (monstera) | top_dry | 24 cm plastic | bright | 7 d (winter: 9 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~1750 ml (until it drains) | 6 |
| Pothos shelf (pothos) | top_dry | 14 cm glazed | medium | 7 d (winter: 8 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~350 ml (until it drains) | 6 |
| Sansa (snake plant) | dry_tolerant | 17 cm terracotta | low | 16 d (winter: 21 d) | soil dry at least 3/4 of the way down | ~450 ml (until it drains) | 3 |
| Zed (zz plant) | dry_tolerant | 20 cm plastic | medium | 20 d | soil dry at least 3/4 of the way down | ~750 ml (until it drains) | 2 |
| Fernando (boston fern) | evenly_moist | 20 cm terracotta | bright | 2 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~1000 ml (until it drains) | 12 |
| Cal (calathea) | evenly_moist | 15 cm plastic | medium | 3 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~450 ml (until it drains) | 12 |
| Spike (cactus) (overdue: check first day) | succulent | 10 cm terracotta | direct | 6 d (winter: 9 d) | soil dry all the way down (chopstick comes out clean and dry) | ~100 ml (until it drains) | 6 |
| Peace lily (peace lily) | evenly_moist | 18 cm glazed, NO hole | low | 4 d (winter: 5 d) | top 1-2 cm just dry; never let it dry out fully | ~375 ml, no more | 12 |
| Orchid (phalaenopsis) | orchid | 12 cm plastic | bright | 5 d (winter: 6 d) | roots silvery-grey and bark dry; soak 10 min, drain fully | soak 10 min | 11 |
| Basil (basil) | herb | 13 cm terracotta | direct | 1 d (winter: 1 d) | top 1-2 cm dry | ~350 ml (until it drains) | 12 |

## Calendar

| Date | Day | Season | Check |
|---|---|---|---|
| 2026-11-02 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-05 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-09 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-12 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-16 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-19 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-23 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-26 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-30 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-12-03 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-12-07 | Mon | winter | Basil, Cal, Fernando, Peace lily, Pothos shelf, Spike |
| 2026-12-10 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily |

## Warnings

| Sev | Plant | Note |
|---|---|---|
| HIGH | Peace lily | pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering |
| LOW | Fernando | terracotta pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist |
| LOW | Fernando | check days push some checks more than a day late (e.g. due 2026-11-07, planned 2026-11-09); add a check day or feel the soil in between |
| LOW | Basil | check days push some checks more than a day late (e.g. due 2026-11-03, planned 2026-11-05); add a check day or feel the soil in between |

Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry.
FILE:scripts/watering_planner.py
#!/usr/bin/env python3
"""Houseplant watering schedule planner (Python 3.8+ standard library only).

Reads a CSV of plants (species or watering type, pot diameter, pot material, light,
drainage, optional last watering date) and builds a check calendar: how often to
check each plant in the current season, what "needs water" means for it, roughly how
much to give, and which plants to check on which date. Intervals stretch
automatically as the plan crosses into autumn and winter (or shrink into summer), by
hemisphere. Checks can be snapped to fixed weekdays (e.g. Mon,Thu) so the plan fits a
routine. Optional .ics export for a calendar app.

A check is not a watering: on each check, feel the soil and water only if the plant's
rule says so. The intervals are starting points; adjust them after two or three weeks
of real checks (see references/adjustment-factors.md).

Usage:
  python3 watering_planner.py plants.csv [--start 2026-10-30] [--weeks 4]
        [--hemisphere north|south] [--humidity low|normal|high]
        [--check-days Mon,Thu] [--ics plan.ics] [--json]
  python3 watering_planner.py --self-test
plants.csv columns: name, plant (species or type), pot_cm, pot_material
(plastic|glazed|ceramic|terracotta|fabric|self-watering), light (low|medium|bright|direct),
drainage (yes|no), last_watered (YYYY-MM-DD, optional), notes (optional).
Types: succulent, dry_tolerant, top_dry, evenly_moist, orchid, herb.
Exit codes: 0 = plan written, 1 = plan written with HIGH warnings (e.g. no drainage), 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, math, sys

PROFILES = {
    #               base days (spring, 15 cm plastic, medium light), winter factor, rule, share of pot volume per watering
    "succulent":    (10, 2.5, "soil dry all the way down (chopstick comes out clean and dry)", 0.15),
    "dry_tolerant": (12, 2.0, "soil dry at least 3/4 of the way down", 0.15),
    "top_dry":      (6, 1.6, "top third of the soil dry (about 3-5 cm in a 15 cm pot)", 0.20),
    "evenly_moist": (3, 1.3, "top 1-2 cm just dry; never let it dry out fully", 0.20),
    "orchid":       (7, 1.4, "roots silvery-grey and bark dry; soak 10 min, drain fully", 0.0),
    "herb":         (2, 1.2, "top 1-2 cm dry", 0.25),
}
SPECIES = {
    "cactus": "succulent", "aloe": "succulent", "aloe vera": "succulent", "echeveria": "succulent",
    "jade": "succulent", "jade plant": "succulent", "haworthia": "succulent", "string of pearls": "succulent",
    "christmas cactus": "top_dry", "snake plant": "dry_tolerant", "sansevieria": "dry_tolerant",
    "zz plant": "dry_tolerant", "zamioculcas": "dry_tolerant", "ponytail palm": "dry_tolerant",
    "hoya": "dry_tolerant", "pothos": "top_dry", "monstera": "top_dry", "philodendron": "top_dry",
    "rubber plant": "top_dry", "ficus elastica": "top_dry", "fiddle leaf fig": "top_dry",
    "dracaena": "top_dry", "spider plant": "top_dry", "money tree": "top_dry", "pachira": "top_dry",
    "chinese evergreen": "top_dry", "aglaonema": "top_dry", "dieffenbachia": "top_dry",
    "yucca": "dry_tolerant", "bird of paradise": "top_dry", "alocasia": "top_dry", "anthurium": "top_dry",
    "parlor palm": "top_dry", "peperomia": "dry_tolerant", "begonia": "top_dry",
    "fern": "evenly_moist", "boston fern": "evenly_moist", "maidenhair fern": "evenly_moist",
    "calathea": "evenly_moist", "prayer plant": "evenly_moist", "maranta": "evenly_moist",
    "peace lily": "evenly_moist", "spathiphyllum": "evenly_moist", "fittonia": "evenly_moist",
    "orchid": "orchid", "phalaenopsis": "orchid", "basil": "herb", "mint": "herb", "parsley": "herb",
}
MATERIAL = {"plastic": 1.0, "glazed": 1.0, "ceramic": 1.0, "metal": 1.0, "terracotta": 0.75,
            "clay": 0.75, "unglazed": 0.75, "fabric": 0.7, "self-watering": 1.5}
LIGHT = {"low": 1.4, "medium": 1.0, "bright": 0.85, "direct": 0.7}
HUMIDITY = {"low": 0.85, "normal": 1.0, "high": 1.15}
SEASON_FACTOR = {"spring": 1.0, "summer": 0.85}
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]


class InputError(Exception):
    pass


def season_of(d, hemisphere):
    m = d.month if hemisphere == "north" else (d.month + 5) % 12 + 1
    return {12: "winter", 1: "winter", 2: "winter", 3: "spring", 4: "spring", 5: "spring",
            6: "summer", 7: "summer", 8: "summer"}.get(m, "autumn")


def pot_factor(cm):
    if cm < 10:
        return 0.6
    if cm < 14:
        return 0.8
    if cm < 20:
        return 1.0
    if cm < 28:
        return 1.3
    return 1.6


def pot_litres(cm):
    # straight-sided pot about 0.9x as tall as wide, minus ~10 % for the rim gap
    r = cm / 2.0
    return math.pi * r * r * (0.9 * cm) * 0.9 / 1000.0


def interval_days(p, season, humidity):
    base, winter, _, _ = PROFILES[p["type"]]
    # autumn sits halfway between spring and the plant's winter slow-down
    sf = {"winter": winter, "autumn": (1 + winter) / 2}.get(season) or SEASON_FACTOR[season]
    days = base * pot_factor(p["pot_cm"]) * MATERIAL[p["material"]] * LIGHT[p["light"]] * HUMIDITY[humidity] * sf
    return max(1, min(45, int(round(days))))


def parse_date(s, what):
    try:
        return dt.date.fromisoformat(s.strip())
    except ValueError:
        raise InputError(f"bad date '{s}' for {what} (use YYYY-MM-DD)")


def load_plants(text):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("plants CSV has no rows")
    cols = {c.strip().lower(): c for c in rows[0] if c}
    if "name" not in cols:
        raise InputError("plants CSV needs at least a 'name' column (plus plant, pot_cm, pot_material, light, drainage)")
    plants, warnings = [], []
    for i, r in enumerate(rows, 2):
        g = lambda k, d="": ((r.get(cols[k]) or "").strip() if k in cols else "") or d
        name = g("name")
        if not name:
            raise InputError(f"row {i}: empty name")
        kind = g("plant", g("type", "")).lower()
        if kind in PROFILES:
            ptype = kind
        elif kind in SPECIES:
            ptype = SPECIES[kind]
        else:
            ptype = next((v for k, v in SPECIES.items() if k in kind), None) if kind else None
            if ptype is None:
                ptype = "top_dry"
                warnings.append(("MEDIUM", name, f"unknown plant '{kind or '?'}': using type top_dry; set the plant column to one of {', '.join(PROFILES)}"))
        try:
            pot = float(g("pot_cm", "0") or 0)
        except ValueError:
            raise InputError(f"row {i} ({name}): pot_cm must be a number of centimetres")
        if pot <= 0:
            pot = 15.0
            warnings.append(("LOW", name, "no pot size given: assumed 15 cm"))
        if pot > 80:
            raise InputError(f"row {i} ({name}): pot_cm {pot} looks wrong (centimetres, not millimetres?)")
        mat = g("pot_material", "plastic").lower()
        if mat not in MATERIAL:
            raise InputError(f"row {i} ({name}): pot_material '{mat}' (use {', '.join(MATERIAL)})")
        light = g("light", "medium").lower().replace("bright indirect", "bright").replace("full sun", "direct")
        if light not in LIGHT:
            raise InputError(f"row {i} ({name}): light '{light}' (use low, medium, bright, direct)")
        drainage = g("drainage", "yes").lower() not in ("no", "n", "false", "0")
        last = parse_date(g("last_watered"), f"{name} last_watered") if g("last_watered") else None
        p = {"name": name, "plant": kind or ptype, "type": ptype, "pot_cm": pot, "material": mat,
             "light": light, "drainage": drainage, "last_watered": last, "notes": g("notes")}
        plants.append(p)
        if not drainage:
            warnings.append(("HIGH", name, "pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering"))
        if ptype == "succulent" and light == "low":
            warnings.append(("MEDIUM", name, "succulent in low light: it will stretch and rot easily; move it to the brightest window and water even less"))
        if ptype == "evenly_moist" and light == "direct":
            warnings.append(("MEDIUM", name, "moisture-loving plant in direct sun: leaves scorch and soil dries within a day; move it out of direct sun"))
        if ptype == "evenly_moist" and mat in ("terracotta", "clay", "unglazed", "fabric"):
            warnings.append(("LOW", name, f"{mat} pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist"))
        if ptype == "orchid" and mat not in ("plastic",):
            warnings.append(("LOW", name, "orchids are easiest in a clear plastic pot with drainage, so you can see the roots"))
        if mat == "self-watering" and ptype in ("succulent", "dry_tolerant", "orchid"):
            warnings.append(("MEDIUM", name, "self-watering pots keep the soil wet; not suited to plants that need to dry out"))
    return plants, warnings


def amount_text(p):
    _, _, _, share = PROFILES[p["type"]]
    if p["type"] == "orchid":
        return "soak 10 min"
    if p["material"] == "self-watering":
        return "refill reservoir"
    ml = pot_litres(p["pot_cm"]) * 1000 * share
    ml = int(round(ml / 50.0) * 50) or 50
    return f"~{ml} ml (until it drains)" if p["drainage"] else f"~{ml // 2} ml, no more"


def snap(due, prev, allowed):
    """Allowed weekday nearest to the due date (ties go earlier), always after prev."""
    if not allowed:
        return due
    best = None
    for off in (0, -1, 1, -2, 2, -3, 3):
        d = due + dt.timedelta(days=off)
        if d > prev and d.weekday() in allowed:
            best = d
            break
    if best is None:
        best = max(due, prev + dt.timedelta(days=1))
        while best.weekday() not in allowed:
            best += dt.timedelta(days=1)
    return best


def plan(plants, start, weeks, hemisphere="north", humidity="normal", check_days=None):
    end = start + dt.timedelta(days=7 * weeks - 1)
    allowed = {DAY_NAMES.index(x) for x in check_days} if check_days else set()
    cal, per_plant, notes = {}, [], []
    for p in plants:
        s0 = season_of(start, hemisphere)
        iv = interval_days(p, s0, humidity)
        if p["last_watered"]:
            due = p["last_watered"] + dt.timedelta(days=iv)
            overdue = due < start
            first = max(due, start)
        else:
            overdue, first = False, start
        d = snap(first, start - dt.timedelta(days=1), allowed) if allowed else first
        dates, prev = [], None
        while d <= end:
            dates.append(d)
            cal.setdefault(d, []).append(p["name"])
            step = interval_days(p, season_of(d, hemisphere), humidity)
            due = d + dt.timedelta(days=step)
            nd = snap(due, d, allowed)
            if allowed and (nd - due).days > max(1, step // 5):
                notes.append((p["name"], due, nd))
            prev, d = d, nd
        later = {season_of(x, hemisphere) for x in dates} - {s0}
        per_plant.append({**p, "interval_now": iv, "season_now": s0,
                          "intervals_later": {s: interval_days(p, s, humidity) for s in sorted(later)},
                          "rule": PROFILES[p["type"]][2], "amount": amount_text(p),
                          "checks": [x.isoformat() for x in dates], "overdue": overdue})
    return {"start": start, "end": end, "hemisphere": hemisphere, "humidity": humidity,
            "check_days": check_days or [], "plants": per_plant,
            "calendar": [(d, sorted(cal[d], key=str.lower)) for d in sorted(cal)], "late_snaps": notes}


def render(res, warnings):
    st, en = res["start"], res["end"]
    out = [f"# Houseplant watering plan: {st.isoformat()} to {en.isoformat()}", "",
           f"Hemisphere: {res['hemisphere']}   Season at start: {season_of(st, res['hemisphere'])}   "
           f"Humidity: {res['humidity']}   Check days: {', '.join(res['check_days']) or 'any day'}   Plants: {len(res['plants'])}", "",
           "A check is not a watering: feel the soil and water only when the rule says so.", "",
           "## Plants", "",
           "| Plant | Type | Pot | Light | Check every | Water when | About | Checks |",
           "|---|---|---|---|---|---|---|---|"]
    for p in res["plants"]:
        iv = f"{p['interval_now']} d"
        if p["intervals_later"]:
            iv += " (" + ", ".join(f"{s}: {v} d" for s, v in p["intervals_later"].items()) + ")"
        pot = f"{p['pot_cm']:g} cm {p['material']}" + ("" if p["drainage"] else ", NO hole")
        flag = " (overdue: check first day)" if p["overdue"] else ""
        out.append(f"| {p['name']} ({p['plant']}){flag} | {p['type']} | {pot} | {p['light']} | {iv} | {p['rule']} | {p['amount']} | {len(p['checks'])} |")
    out += ["", "## Calendar", "", "| Date | Day | Season | Check |", "|---|---|---|---|"]
    for d, names in res["calendar"]:
        out.append(f"| {d.isoformat()} | {DAY_NAMES[d.weekday()]} | {season_of(d, res['hemisphere'])} | {', '.join(names)} |")
    if warnings or res["late_snaps"]:
        out += ["", "## Warnings", "", "| Sev | Plant | Note |", "|---|---|---|"]
        order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
        for sev, name, msg in sorted(warnings, key=lambda w: (order[w[0]], w[1].lower())):
            out.append(f"| {sev} | {name} | {msg} |")
        seen = set()
        for name, due, nd in res["late_snaps"]:
            if name in seen:
                continue
            seen.add(name)
            out.append(f"| LOW | {name} | check days push some checks more than a day late (e.g. due {due.isoformat()}, planned {nd.isoformat()}); add a check day or feel the soil in between |")
    out += ["", "Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry."]
    return "\n".join(out) + "\n"


def to_ics(res, stamp):
    lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//houseplant-watering-schedule-planner//EN", "CALSCALE:GREGORIAN"]
    for d, names in res["calendar"]:
        nd = d + dt.timedelta(days=1)
        lines += ["BEGIN:VEVENT", f"UID:plants-{d.strftime('%Y%m%d')}@watering-planner", f"DTSTAMP:{stamp}",
                  f"DTSTART;VALUE=DATE:{d.strftime('%Y%m%d')}", f"DTEND;VALUE=DATE:{nd.strftime('%Y%m%d')}",
                  "SUMMARY:" + ics_escape("Check plants: " + ", ".join(names)), "TRANSP:TRANSPARENT", "END:VEVENT"]
    lines.append("END:VCALENDAR")
    return "\r\n".join(fold(l) for l in lines) + "\r\n"


def ics_escape(s):
    return s.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")


def fold(line):
    b = line.encode("utf-8")
    if len(b) <= 75:
        return line
    parts, cur = [], b""
    for ch in line:
        e = ch.encode("utf-8")
        if len(cur) + len(e) > (75 if not parts else 74):
            parts.append(cur.decode("utf-8")); cur = b""
        cur += e
    parts.append(cur.decode("utf-8"))
    return "\r\n ".join(parts)


def self_test():
    H = "name,plant,pot_cm,pot_material,light,drainage,last_watered\n"
    def run(rows, **kw):
        plants, warns = load_plants(H + rows)
        return plan(plants, kw.pop("start", dt.date(2026, 4, 6)), kw.pop("weeks", 4), **kw), warns
    cases = []
    def case(name, fn):
        try:
            ok = bool(fn())
            err = ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))

    case("succulent checked less often than fern", lambda: run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][1]["interval_now"])
    case("base interval top_dry 15 cm plastic medium spring = 6", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] == 6)
    case("small pot dries faster", lambda: run("A,pothos,9,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("big pot lasts longer", lambda: run("A,pothos,30,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > 6)
    case("terracotta dries faster than plastic", lambda: run("A,pothos,15,terracotta,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("low light lengthens, direct sun shortens", lambda: run("A,pothos,15,plastic,low,yes,\n")[0]["plants"][0]["interval_now"] > 6 > run("A,pothos,15,plastic,direct,yes,\n")[0]["plants"][0]["interval_now"])
    case("winter stretches succulent 2.5x", lambda: run("A,cactus,15,plastic,medium,yes,\n", start=dt.date(2026, 1, 5))[0]["plants"][0]["interval_now"] == 25)
    case("summer shortens", lambda: run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 7, 6))[0]["plants"][0]["interval_now"] == 5)
    case("southern hemisphere: July is winter", lambda: season_of(dt.date(2026, 7, 1), "south") == "winter" and season_of(dt.date(2026, 1, 1), "south") == "summer")
    case("season boundaries north", lambda: [season_of(dt.date(2026, m, 15), "north") for m in (2, 3, 5, 6, 8, 9, 11, 12)] == ["winter", "spring", "spring", "summer", "summer", "autumn", "autumn", "winter"])
    case("interval grows across autumn->winter in plan", lambda: (lambda r: r["plants"][0]["intervals_later"].get("winter", 0) > r["plants"][0]["interval_now"])(run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 11, 16), weeks=4)[0]))
    case("checks spaced by interval", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["checks"][:3] == ["2026-04-06", "2026-04-12", "2026-04-18"])
    case("last_watered sets first check", lambda: run("A,pothos,15,plastic,medium,yes,2026-04-04\n")[0]["plants"][0]["checks"][0] == "2026-04-10")
    case("overdue goes to start", lambda: (lambda p: p["overdue"] and p["checks"][0] == "2026-04-06")(run("A,pothos,15,plastic,medium,yes,2026-03-20\n")[0]["plants"][0]))
    case("snap to check days (Mon,Thu)", lambda: all(dt.date.fromisoformat(x).weekday() in (0, 3) for x in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["calendar"] and [x for p in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["plants"] for x in p["checks"]]))
    case("snap to nearest check day (Sun -> Mon)", lambda: snap(dt.date(2026, 4, 12), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 13))
    case("snap tie goes earlier (Tue between Mon and Thu? no: Tue -> Mon)", lambda: snap(dt.date(2026, 4, 14), dt.date(2026, 4, 9), {0, 3}) == dt.date(2026, 4, 13))
    case("snap never on or before the previous check", lambda: snap(dt.date(2026, 4, 7), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 9))
    case("autumn between spring and winter", lambda: 6 < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 10, 5))[0]["plants"][0]["interval_now"] < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 12, 7))[0]["plants"][0]["interval_now"])
    case("late snap noted for fast plant", lambda: len(run("A,basil,9,terracotta,direct,yes,\n", check_days=["Mon"])[0]["late_snaps"]) > 0)
    case("no drainage -> HIGH warning and half amount", lambda: (lambda r: any(w[0] == "HIGH" for w in r[1]) and "no more" in r[0]["plants"][0]["amount"])(run("A,pothos,15,glazed,medium,no,\n")))
    case("unknown plant -> top_dry with warning", lambda: (lambda r: r[0]["plants"][0]["type"] == "top_dry" and any("unknown plant" in w[2] for w in r[1]))(run("A,triffid,15,plastic,medium,yes,\n")))
    case("species substring match", lambda: run("A,variegated monstera,15,plastic,medium,yes,\n")[0]["plants"][0]["type"] == "top_dry")
    case("succulent low light warning", lambda: any("stretch" in w[2] for w in run("A,echeveria,10,terracotta,low,yes,\n")[1]))
    case("fern in direct sun warning", lambda: any("scorch" in w[2] for w in run("A,boston fern,20,plastic,direct,yes,\n")[1]))
    case("self-watering succulent warning", lambda: any("self-watering" in w[2] for w in run("A,cactus,15,self-watering,bright,yes,\n")[1]))
    case("orchid amount is soak", lambda: run("A,orchid,12,plastic,bright,yes,\n")[0]["plants"][0]["amount"] == "soak 10 min")
    case("amount scales with pot", lambda: int(run("A,pothos,25,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]) > int(run("A,pothos,12,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]))
    case("low humidity shortens", lambda: run("A,fern,20,plastic,medium,yes,\n", humidity="low")[0]["plants"][0]["interval_now"] <= run("A,fern,20,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"])
    case("interval never below 1 day", lambda: run("A,basil,6,fabric,direct,yes,\n", start=dt.date(2026, 7, 6), humidity="low")[0]["plants"][0]["interval_now"] >= 1)
    case("missing pot size assumed 15", lambda: (lambda r: r[0]["plants"][0]["pot_cm"] == 15 and any("assumed 15" in w[2] for w in r[1]))(run("A,pothos,,plastic,medium,yes,\n")))
    def bad(rows):
        try:
            load_plants(H + rows); return False
        except InputError:
            return True
    case("bad material rejected", lambda: bad("A,pothos,15,wood,medium,yes,\n"))
    case("bad light rejected", lambda: bad("A,pothos,15,plastic,dim,yes,\n"))
    case("pot in mm rejected", lambda: bad("A,pothos,150,plastic,medium,yes,\n"))
    case("bad date rejected", lambda: bad("A,pothos,15,plastic,medium,yes,30/10/2026\n"))
    case("ics has one event per date, CRLF, escaped commas", lambda: (lambda r, t: t.count("BEGIN:VEVENT") == len(r["calendar"]) and "\r\n" in t and "\\," in t)(*(lambda r: (r, to_ics(r, "20261030T000000Z")))(run("A,pothos,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0])))
    case("ics lines folded to 75 octets", lambda: all(len(l.encode()) <= 75 for l in to_ics(run("".join(f"Plant number {i},pothos,15,plastic,medium,yes,\n" for i in range(8)))[0], "20261030T000000Z").split("\r\n")))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Build a houseplant watering check calendar")
    ap.add_argument("plants", nargs="?")
    ap.add_argument("--start", help="first day of the plan, YYYY-MM-DD (default today)")
    ap.add_argument("--weeks", type=int, default=4)
    ap.add_argument("--hemisphere", choices=["north", "south"], default="north")
    ap.add_argument("--humidity", choices=list(HUMIDITY), default="normal", help="low = heating on / below ~35 %%")
    ap.add_argument("--check-days", help="snap checks to these weekdays, e.g. Mon,Thu")
    ap.add_argument("--ics", help="also write an .ics calendar file")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.plants:
        ap.error("give a plants CSV, or --self-test")
    try:
        start = parse_date(a.start, "--start") if a.start else dt.date.today()
        if not 1 <= a.weeks <= 26:
            raise InputError("--weeks must be 1..26")
        days = None
        if a.check_days:
            days = [x.strip()[:3].title() for x in a.check_days.split(",") if x.strip()]
            for x in days:
                if x not in DAY_NAMES:
                    raise InputError(f"unknown weekday '{x}' in --check-days")
        with open(a.plants, encoding="utf-8") as fh:
            plants, warnings = load_plants(fh.read())
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    res = plan(plants, start, a.weeks, a.hemisphere, a.humidity, days)
    if a.ics:
        stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        with open(a.ics, "w", encoding="utf-8", newline="") as fh:
            fh.write(to_ics(res, stamp))
    if a.json:
        out = {**res, "start": res["start"].isoformat(), "end": res["end"].isoformat(),
               "calendar": [{"date": d.isoformat(), "plants": n} for d, n in res["calendar"]],
               "late_snaps": [{"plant": n, "due": d.isoformat(), "planned": p.isoformat()} for n, d, p in res["late_snaps"]],
               "plants": [{**p, "last_watered": p["last_watered"].isoformat() if p["last_watered"] else None} for p in res["plants"]],
               "warnings": [{"severity": s, "plant": n, "note": m} for s, n, m in warnings]}
        print(json.dumps(out, indent=2))
    else:
        sys.stdout.write(render(res, warnings))
    return 1 if any(w[0] == "HIGH" for w in warnings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonGardeningPlanning+1
F@f
0
Commute Cost Comparison Calculator
Skill

Compares commute options - car (fuel or EV), transit, bike, e-bike, walking, day-share mixes and park-and-ride legs - per month and year with cash cost, door-to-door time, time cost at your own hourly value, cost per trip and CO2. Picks pass vs pay-per-ride for the days you ride, separates car running costs from fixed costs, and shows the break-even value of an hour. Includes a stdlib Python calculator with --self-test.

---
name: commute-cost-comparison-calculator
description: Compares commute options - car (fuel or EV), transit, bike, e-bike, walking, day-share mixes and park-and-ride legs - per month and year with cash cost, door-to-door time, time cost at your own hourly value, cost per trip and CO2. Picks pass vs pay-per-ride for the days you ride, separates car running costs from fixed costs, and shows the break-even value of an hour. Includes a stdlib Python calculator with --self-test.
---

# Commute Cost Comparison Calculator

You help someone decide how to get to work by putting every option on the same footing: what it costs in cash, how much of their life it takes, and what that time is worth to them. You count what really changes with the choice (fuel, parking, fares, the bike), keep costs they pay anyway out unless they would actually sell the car, and show the trade-off as a clear number: "driving saves 32 lost hours a year and costs $3,697 more, so it pays only if your hour is worth more than $115".

## Files in this skill

- `scripts/commute_cost.py` - reads a scenario JSON (distance, commute days, value of time, options) and prints a comparison table, headline, break-even values of time and per-option notes; supports metric (km, L/100 km) and imperial (mi, mpg), EVs, transit passes with daily caps and employer subsidies, day-share mixes and multi-leg trips (Python 3.8+ standard library only)
- `references/cost-components.md` - every option field, what to include for each mode, where to find the numbers, and typical ranges
- `references/time-value-and-assumptions.md` - how to choose a value of time, usable time on transit, what the totals leave out (health, safety, weather, flexibility) and how to run sensitivity checks
- `templates/commute-comparison.md` - the decision summary to hand back
- `examples/commute.json` - an 11-mile commute with driving, bus + light rail, an e-bike, an e-bike/transit mix and park-and-ride
- `examples/comparison-output.md` - the real output of the calculator for that scenario

## Workflow

### 1. Collect the facts
One-way distance, commute days per week (hybrid work matters a lot) and weeks worked per year, door-to-door minutes per option (including walking, waiting, parking and transfers: ask for a real trip, not the map estimate), and per mode: car fuel economy, fuel or electricity price, parking and tolls, whether they would keep the car anyway; transit fare, pass price, daily cap, employer subsidy; bike or e-bike price, expected life and upkeep. Ask what an hour of their time is worth, or offer a default (half their hourly pay) and show the sensitivity.

### 2. Run the calculator
```
python3 scripts/commute_cost.py examples/commute.json
python3 scripts/commute_cost.py scenario.json --days-per-week 3
python3 scripts/commute_cost.py scenario.json --value-of-time 0 --json
python3 scripts/commute_cost.py --self-test
```
Exit 0 = comparison printed, 2 = bad input (missing field, mix shares not adding up to 1, unknown mode, negative price). Without code, follow the formulas in `references/cost-components.md`.

### 3. Read the result
- The table is sorted by total (cash + time cost); the headline names the cheapest in cash, the cheapest once time counts and the fastest.
- **Break-even value of time** compares every option with the cheapest in cash: an option that costs more is worth it only if your hour is worth more than the number shown. "Never" means it costs more and does not save usable time.
- Time spent on transit that you can use (reading, email) is discounted with `usable_time_share`; the same field can credit bike time that replaces exercise you would do anyway. Say which you used.
- Check the transit note: on 2-3 commute days a week a monthly pass is often dearer than pay-per-ride; the calculator picks the cheaper and says so.

### 4. Deliver
Fill `templates/commute-comparison.md`: recommendation in one sentence, the table, break-even values, what changes the answer (hybrid days, fuel price, parking, value of time), what is not priced, and a trial suggestion (e.g. two weeks of the mix before buying anything).

## Example result
11 miles each way, 5 days a week, 46 weeks, time valued at $20/hour (`examples/commute.json`). Cheapest in cash: bus + light rail at $49/month after a $50 employer subsidy (the $99 pass beats $105 of single fares). Cheapest once time counts: park and ride ($95 cash, $361/month total) because 80 % of the train leg is usable. Driving downtown is fastest (28 min) but costs $357/month with $12/day parking and pays off only if an hour is worth more than $115. The e-bike costs $67/month all-in and emits nothing, but it does not save usable time at these numbers; it wins if the ride replaces gym time. See `examples/comparison-output.md`.

## Rules

- Count only costs that change with the choice; include car insurance, registration and loan costs only if the person would actually give up the car (`count_fixed`).
- Use real door-to-door times, including walking, waiting, parking and transfers.
- Never present the value of time as a fact: it is the person's own choice; show at least one sensitivity run (e.g. `--value-of-time 0` and their wage).
- Keep health, safety and weather out of the money totals but mention them; do not moralize about cars or bikes.
- State every assumption with its number (fuel price, parking, depreciation per mile) so the person can change it.
- Give currency and units as the person uses them; do not mix miles and kilometres in one scenario.
FILE:references/cost-components.md
# Cost components and option fields

Scenario fields: `currency` (USD, EUR, GBP, TRY, ...), `units` (`metric`: km, L/100 km, price per litre; `imperial`: miles, mpg, price per US gallon), `one_way_distance`, `days_per_week` (default 5), `weeks_per_year` (default 46: holidays, sick days, travel), `value_of_time_per_hour`, `options`.

Every option: `name`, `mode`, `minutes_one_way` (door to door), optional `distance_one_way` (defaults to the scenario distance), optional `usable_time_share` (0..1).

## Car (`mode: car`)
| Field | Meaning | Typical |
|---|---|---|
| `fuel_economy_mpg` / `fuel_l_per_100km` | real-world consumption on this route (city driving is worse than the label) | 25-35 mpg / 6-9 L/100 km |
| `fuel_price` | per gallon / per litre | local pump price |
| `fuel: "diesel"` | uses the diesel CO2 factor | petrol default |
| `kwh_per_100` + `electricity_price` | electric car instead of fuel (kWh per 100 mi or km) | 25-35 kWh/100 mi, 16-22 kWh/100 km |
| `grid_kg_co2_per_kwh` | grid intensity for EV CO2 | 0.4 default; 0.05 hydro/nuclear, 0.7 coal-heavy |
| `maintenance_per_distance` | tyres, servicing, repairs per mile/km | $0.08-0.12/mi, 0.05-0.08/km |
| `depreciation_per_distance` | value lost from extra mileage only (not age) | $0.05-0.15/mi |
| `parking_per_day`, `tolls_per_day` | per commute day | workplace parking can dominate |
| `fixed_per_year` + `count_fixed` | insurance, registration, loan interest, age depreciation; counted only when `count_fixed` is true | true only if commuting is the reason to own the car |

Fuel cost = 2 x distance x days / mpg x price (or x L/100 km / 100 x price). CO2: petrol 2.35 kg/L (8.89 kg per US gallon), diesel 2.68 kg/L (10.18 kg/gal), EV kWh x grid factor.

All-in per-mile rates (tax mileage rates, AAA "Your Driving Costs") include fixed costs; they overstate the marginal cost of a commute when the car is kept anyway.

## Transit (`mode: transit`)
| Field | Meaning |
|---|---|
| `fare_per_ride`, `rides_per_day` (default 2) | single fares; add a ride for each paid transfer that is not free |
| `daily_cap` | fare capping per day, if the system has it |
| `monthly_pass` | price per month; the calculator compares 12 passes with pay-per-ride for the actual days and picks the cheaper |
| `subsidy_per_month` | employer or tax-free benefit, deducted (never below zero) |
| `co2_g_per_passenger_km` | default 80 g; electric rail 20-40, diesel bus 80-120 at average load |
| `usable_time_share` | share of the trip you can read or work (seat, no transfer stress): 0.2-0.8 |

## Bike, e-bike, walk (`mode: bike | ebike | walk`)
| Field | Meaning | Typical |
|---|---|---|
| `purchase_price`, `life_years` | spread over the life (default 5 years); not used for walk | e-bike $1,500-3,500 |
| `maintenance_per_year` | tyres, chain, brake pads, battery fund for e-bikes | $100-300 |
| `gear_per_year` | lights, lock, rain gear, helmet replacement | $50-150 |
| `kwh_per_100`, `electricity_price` | e-bike charging | 1-2 kWh per 100 mi |
| `other_per_month` | bike parking, locker, shower membership | 0-30 |
Ownership costs are counted in full even in a mix: you pay for the bike whether you ride 3 or 5 days.

## Mix (`mode: mix`)
`"shares": {"E-bike": 0.6, "Bus + light rail": 0.4}` splits commute days between other options (shares add up to 1). Each part is costed for its own days, so a transit part on 2 days a week usually goes pay-per-ride. Use it for weather-dependent cycling, hybrid driving, or carpool days.

## Multi-leg (`mode: multi`)
`"legs": [ {car leg}, {transit leg} ]` with `transfer_minutes`. Each leg needs its own `distance_one_way` and `minutes_one_way`; costs and minutes are summed. Use it for park and ride, bike + train, or walk + bus.

## Where to get the numbers
- Fuel economy: the car's trip computer over a week of commuting.
- Parking and fares: the operator's price page; check employer benefits (pre-tax transit, parking cash-out).
- Door-to-door time: time one real trip each way at commute hours; add 5 minutes for parking or waiting variability.
FILE:references/time-value-and-assumptions.md
# Value of time and what the totals leave out

## Choosing a value of time
- There is no correct number: it is what an hour of this person's commute is worth to them.
- Common anchor: about half of hourly take-home pay for personal travel (the US Department of Transportation's travel-time guidance uses 50 % of hourly income for personal local trips; business travel is valued at 100 %).
- Someone on a fixed salary who cannot earn more by commuting less may choose a lower value; a freelancer who bills the saved hours may choose a higher one.
- Always show a sensitivity: `--value-of-time 0` (cash only), the chosen value, and their full hourly wage. If the ranking does not change, the decision is robust.

## Usable time
`usable_time_share` discounts time you can use. Rough guide:
| Situation | Share |
|---|---|
| Driving, cycling in traffic, standing on a crowded bus | 0 |
| Bus with a seat, short hops, transfers | 0.2-0.4 |
| Train with a seat and table, 20+ minutes without changes | 0.6-0.8 |
| Bike or walk that replaces exercise you would otherwise do | 0.3-0.7 (say so explicitly) |

## Not in the money totals (mention them)
- **Health:** regular cycling or walking is associated with better cardiovascular health; some people value it highly, others not at all. Use `usable_time_share` if they want it priced.
- **Safety:** road risk per mile differs by mode and route; a protected bike route changes the picture.
- **Weather and season:** use a mix (e.g. 60 % bike days) rather than assuming every day is a bike day.
- **Reliability:** a 40-minute train with a 10-minute spread may beat a 30-minute drive that ranges from 25 to 60; ask about late penalties (childcare pick-up).
- **Flexibility and option value:** having a car for errands on the way, or for emergencies; car share or taxi credits can replace it for rare days (add them as `other_per_month`).
- **Stress and comfort:** do not price it, but ask.

## Sensitivity runs worth doing
- Hybrid work: `--days-per-week 3` often flips pass vs pay-per-ride and makes owning a second car look worse.
- Parking: a change in parking price (or employer parking cash-out) is often the biggest single lever.
- Fuel or electricity price +-25 %.
- Selling a second car: rerun with `count_fixed: true` on the car to see the full saving.

## Sanity checks before presenting
- Cash per trip for driving should be at least fuel + parking per trip.
- Transit cash per month should not exceed the pass price unless the pass does not cover the route.
- Hours per month = minutes one way x 2 x days per month / 60.
- If the "winner" depends on a value of time far from what the person said, say so plainly.
FILE:templates/commute-comparison.md
# Commute options: <home> to <work>

**Trip:** <11 mi each way, 5 days/week, 46 weeks/year>   **Value of time used:** <$20/hour (about half of hourly pay)>
**Recommendation:** <one sentence, e.g. "Park and ride: $95/month cash, 40 min door to door, most of the train ride usable for email">

## Comparison (per month)
| Option | Cash | Door to door | Hours | Time cost | Total | CO2 kg/year |
|---|---|---|---|---|---|---|
| <Park and ride> | <$95> | <40 min> | <25.6> | <$266> | <$361> | <999> |
| <Bus + light rail> | <$49> | <46 min> | <29.4> | <$411> | <$460> | <651> |

## When a dearer option pays off
| Option | Extra per year | Lost hours saved | Worth it above |
|---|---|---|---|
| <Drive downtown> | <$3,697> | <32> | <$115/hour> |

## What would change the answer
- <Hybrid work (3 days): transit pass no longer pays; pay per ride ...>
- <Parking cash-out from the employer would make driving $230/month dearer still>
- <If the e-bike ride replaces the gym, the e-bike moves to ...>

## Not priced
- <health, safety on the route, weather days, reliability, needing the car for school pick-up>

## Assumptions
- <fuel $3.40/gal at 28 mpg; maintenance $0.10/mi; mileage depreciation $0.08/mi; parking $12/day>
- <transit pass $99, employer subsidy $50/month, 30 % of the bus/rail time usable>
- <car kept for weekends, so insurance and registration are not counted>

## Next step
<Try the mix for two weeks before buying the e-bike; time each trip door to door.>
FILE:examples/commute.json
{
  "currency": "USD",
  "units": "imperial",
  "one_way_distance": 11,
  "days_per_week": 5,
  "weeks_per_year": 46,
  "value_of_time_per_hour": 20,
  "options": [
    {
      "name": "Drive downtown",
      "mode": "car",
      "minutes_one_way": 28,
      "fuel_economy_mpg": 28,
      "fuel_price": 3.4,
      "maintenance_per_distance": 0.1,
      "depreciation_per_distance": 0.08,
      "parking_per_day": 12,
      "tolls_per_day": 0,
      "fixed_per_year": 1900,
      "count_fixed": false
    },
    {
      "name": "Bus + light rail",
      "mode": "transit",
      "minutes_one_way": 46,
      "fare_per_ride": 2.75,
      "monthly_pass": 99,
      "subsidy_per_month": 50,
      "usable_time_share": 0.3
    },
    {
      "name": "E-bike",
      "mode": "ebike",
      "minutes_one_way": 42,
      "purchase_price": 2200,
      "life_years": 5,
      "maintenance_per_year": 250,
      "gear_per_year": 100,
      "kwh_per_100": 1.5,
      "electricity_price": 0.17
    },
    {
      "name": "E-bike 3 days, transit 2 days",
      "mode": "mix",
      "shares": {"E-bike": 0.6, "Bus + light rail": 0.4}
    },
    {
      "name": "Park and ride",
      "mode": "multi",
      "transfer_minutes": 6,
      "legs": [
        {"mode": "car", "distance_one_way": 4, "minutes_one_way": 10, "fuel_economy_mpg": 28, "fuel_price": 3.4,
         "maintenance_per_distance": 0.1, "depreciation_per_distance": 0.08, "parking_per_day": 0},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 24, "fare_per_ride": 2.75, "monthly_pass": 99,
         "subsidy_per_month": 50, "usable_time_share": 0.8}
      ]
    }
  ]
}
FILE:examples/comparison-output.md
# Commute cost comparison

One way: 11 mi   Commute days: 19.2/month (230/year)   Value of time: $20.00/hour   Currency: USD

| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |
|---|---|---|---|---|---|---|---|---|
| Park and ride | $95.22 | $1,143 | $2.48 | 40 min | 25.6 | $266 | **$361** | 999 |
| Bus + light rail | $49.00 | $588 | $1.28 | 46 min | 29.4 | $411 | **$460** | 651 |
| E-bike 3 days, transit 2 days | $66.48 | $798 | $1.73 | 44 min | 27.9 | $487 | **$553** | 261 |
| E-bike | $66.91 | $803 | $1.75 | 42 min | 26.8 | $537 | **$604** | 0 |
| Drive downtown | $357 | $4,285 | $9.32 | 28 min | 17.9 | $358 | **$715** | 1,607 |

Sorted by total (cash + time cost).

## Headline

- Cheapest in cash: **Bus + light rail** ($49.00/month).
- Cheapest once time counts: **Park and ride** ($361/month at $20.00/hour).
- Fastest: **Drive downtown** (28 min one way, 17.9 h/month).

## Break-even value of time (vs Bus + light rail, the cheapest in cash)

| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |
|---|---|---|---|
| Drive downtown | $3,697 | 32 | $115/hour |
| E-bike | $215 | none (75 more lost) | never on cost: it does not save usable time |
| E-bike 3 days, transit 2 days | $210 | none (45 more lost) | never on cost: it does not save usable time |
| Park and ride | $555 | 87 | $6.35/hour |

## Notes per option

- **Drive downtown** (car): per month: fuel/energy $51.20, maintenance + depreciation $75.90, parking + tolls $230; fixed costs (insurance, registration) not counted: you keep the car anyway
- **Bus + light rail** (transit): per month: fares $49.00; monthly pass (cheaper than $105/month pay per ride at 19.2 days/month); employer subsidy $50.00/month deducted
- **E-bike** (ebike): per month: bike + upkeep $65.83, fuel/energy $1.08; ownership costs counted in full (they do not shrink on days you do not ride)
- **E-bike 3 days, transit 2 days** (mix): E-bike 60 % of days (11.5/month): ownership costs counted in full (they do not shrink on days you do not ride); Bus + light rail 40 % of days (7.7/month): pay per ride (pass would cost $99.00/month at 7.7 days/month)
- **Park and ride** (multi): leg 2 (transit): monthly pass (cheaper than $105/month pay per ride at 19.2 days/month); leg 2 (transit): employer subsidy $50.00/month deducted

Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). Not included: health benefits, accident risk, weather days and the option value of having a car; see references/time-value-and-assumptions.md.
FILE:scripts/commute_cost.py
#!/usr/bin/env python3
"""Commute cost comparison calculator (Python 3.8+ standard library only).

Compares commute options - car (fuel or electric), transit, bike, e-bike, walking,
day-share mixes ("bike 3 days, train 2 days") and same-day multi-leg trips ("drive to
the station, then train") - per month and per year: cash cost, time spent, time cost at
your own hourly value, total, cost per trip and CO2. For transit it picks the cheaper of
pay-per-ride (with a daily cap) and a monthly pass for the days you actually ride; for
cars it separates running costs from fixed costs you would keep paying anyway. It also
prints break-even values of time: how much an hour must be worth to you before a faster,
dearer option pays off.

Usage:
  python3 commute_cost.py scenario.json [--value-of-time 25] [--days-per-week 3] [--json]
  python3 commute_cost.py --self-test
scenario.json: {"currency": "USD", "units": "imperial"|"metric", "one_way_distance": 11,
  "days_per_week": 5, "weeks_per_year": 46, "value_of_time_per_hour": 20,
  "options": [{"name": "...", "mode": "car|transit|bike|ebike|walk|mix|multi", ...}]}
See references/cost-components.md for every option field.
Exit codes: 0 = comparison printed, 2 = bad input.
"""
import argparse, json, sys

PETROL_CO2 = {"metric": 2.35, "imperial": 8.89}   # kg CO2 per litre / per US gallon burned (diesel: 2.68 / 10.18)
DIESEL_CO2 = {"metric": 2.68, "imperial": 10.18}
KM_PER_UNIT = {"metric": 1.0, "imperial": 1.609344}
LABELS = {"energy": "fuel/energy", "running": "maintenance + depreciation", "parking_tolls": "parking + tolls",
          "fixed": "fixed costs", "fares": "fares", "ownership": "bike + upkeep", "other": "other"}
MODES = {"car", "transit", "bike", "ebike", "walk", "mix", "multi"}
SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "TRY": "₺", "CAD": "C$", "AUD": "A$", "JPY": "¥", "INR": "₹"}


class InputError(Exception):
    pass


def num(o, key, default=None, lo=0.0, hi=None, who=""):
    v = o.get(key, default)
    if v is None:
        raise InputError(f"{who}: '{key}' is required")
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise InputError(f"{who}: '{key}' must be a number")
    if v < lo or (hi is not None and v > hi):
        raise InputError(f"{who}: '{key}' = {v} is out of range" + (f" ({lo}..{hi})" if hi is not None else f" (>= {lo})"))
    return float(v)


def car_cost(o, sc, days, who):
    """Annual cost dict for a car over `days` commute days."""
    units = sc["units"]
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days      # user units per year
    if "kwh_per_100" in o:
        kwh = d / 100 * num(o, "kwh_per_100", who=who)
        energy = kwh * num(o, "electricity_price", who=who)
        co2 = kwh * num(o, "grid_kg_co2_per_kwh", 0.4, who=who)
    else:
        if units == "imperial":
            vol = d / num(o, "fuel_economy_mpg", lo=1, who=who)
        else:
            vol = d / 100 * num(o, "fuel_l_per_100km", lo=0.5, who=who)
        energy = vol * num(o, "fuel_price", who=who)
        co2 = vol * (DIESEL_CO2 if o.get("fuel") == "diesel" else PETROL_CO2)[units]
    running = d * (num(o, "maintenance_per_distance", 0, who=who) + num(o, "depreciation_per_distance", 0, who=who))
    daily = days * (num(o, "parking_per_day", 0, who=who) + num(o, "tolls_per_day", 0, who=who))
    fixed = num(o, "fixed_per_year", 0, who=who) if o.get("count_fixed", False) else 0.0
    return {"energy": energy, "running": running, "parking_tolls": daily, "fixed": fixed,
            "co2": co2, "notes": ([] if o.get("count_fixed", False) or not o.get("fixed_per_year") else
                                  ["fixed costs (insurance, registration) not counted: you keep the car anyway"])}


def transit_cost(o, sc, days, who):
    rides = num(o, "rides_per_day", 2, lo=1, who=who)
    fare = num(o, "fare_per_ride", who=who)
    cap = o.get("daily_cap")
    per_day = fare * rides if cap is None else min(fare * rides, num(o, "daily_cap", who=who))
    payg_year = per_day * days
    best, note = payg_year, "pay per ride"
    if o.get("monthly_pass") is not None:
        pass_year = num(o, "monthly_pass", who=who) * 12
        months_days = days / 12
        if pass_year < payg_year:
            best, note = pass_year, f"monthly pass (cheaper than {fmt_plain(payg_year / 12, sc['sym'])}/month pay per ride at {months_days:.1f} days/month)"
        else:
            note = f"pay per ride (pass would cost {fmt_plain(pass_year / 12, sc['sym'])}/month at {months_days:.1f} days/month)"
    subsidy = num(o, "subsidy_per_month", 0, who=who) * 12
    best = max(0.0, best - subsidy)
    km = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days * KM_PER_UNIT[sc["units"]]
    co2 = km * num(o, "co2_g_per_passenger_km", 80, who=who) / 1000
    return {"fares": best, "co2": co2, "notes": [note] + ([f"employer subsidy {fmt_plain(subsidy / 12, sc['sym'])}/month deducted"] if subsidy else [])}


def bike_cost(o, sc, days, who, mode):
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days
    own = 0.0
    if mode != "walk":
        life = num(o, "life_years", 5, lo=0.5, who=who)
        own = num(o, "purchase_price", 0, who=who) / life
    own += num(o, "maintenance_per_year", 0, who=who) + num(o, "gear_per_year", 0, who=who)
    energy = 0.0
    if mode == "ebike":
        kwh = d / 100 * num(o, "kwh_per_100", 1.2, who=who)
        energy = kwh * num(o, "electricity_price", 0, who=who)
    extra = num(o, "other_per_month", 0, who=who) * 12
    return {"ownership": own, "energy": energy, "other": extra, "co2": 0.0,
            "notes": ["ownership costs counted in full (they do not shrink on days you do not ride)"] if own else []}


def option_cost(o, sc, days, who):
    mode = o.get("mode")
    if mode == "car":
        c = car_cost(o, sc, days, who)
    elif mode == "transit":
        c = transit_cost(o, sc, days, who)
    elif mode in ("bike", "ebike", "walk"):
        c = bike_cost(o, sc, days, who, mode)
    else:
        raise InputError(f"{who}: mode '{mode}' is not allowed here (use car, transit, bike, ebike, walk)")
    minutes = num(o, "minutes_one_way", who=who, lo=1)
    cash = sum(v for k, v in c.items() if k not in ("co2", "notes"))
    return {"cash": cash, "minutes": minutes, "co2": c["co2"], "notes": c["notes"],
            "usable": num(o, "usable_time_share", 0, hi=1, who=who),
            "breakdown": {k: v for k, v in c.items() if k not in ("co2", "notes")}}


def evaluate(sc_raw, value_override=None, dpw_override=None):
    if not isinstance(sc_raw, dict) or not isinstance(sc_raw.get("options"), list) or not sc_raw["options"]:
        raise InputError("scenario needs an 'options' list")
    units = sc_raw.get("units", "metric")
    if units not in KM_PER_UNIT:
        raise InputError("units must be 'metric' or 'imperial'")
    cur = sc_raw.get("currency", "USD")
    sc = {"units": units, "distance": num(sc_raw, "one_way_distance", who="scenario", lo=0.1),
          "sym": SYMBOLS.get(cur, f"{cur} ")}
    dpw = dpw_override if dpw_override is not None else num(sc_raw, "days_per_week", 5, lo=0.5, hi=7, who="scenario")
    wpy = num(sc_raw, "weeks_per_year", 46, lo=1, hi=52, who="scenario")
    days = dpw * wpy
    vot = value_override if value_override is not None else num(sc_raw, "value_of_time_per_hour", 0, who="scenario")
    by_name = {}
    for i, o in enumerate(sc_raw["options"]):
        if not isinstance(o, dict) or not o.get("name") or o.get("mode") not in MODES:
            raise InputError(f"option {i + 1}: needs a 'name' and a 'mode' ({', '.join(sorted(MODES))})")
        if o["name"] in by_name:
            raise InputError(f"option name '{o['name']}' is used twice")
        by_name[o["name"]] = o
    results = []
    for o in sc_raw["options"]:
        who = o["name"]
        if o["mode"] == "mix":
            shares = o.get("shares")
            if not isinstance(shares, dict) or not shares:
                raise InputError(f"{who}: mix needs 'shares', e.g. {{\"Bike\": 0.6, \"Transit\": 0.4}}")
            tot = sum(shares.values())
            if abs(tot - 1) > 0.001:
                raise InputError(f"{who}: shares add up to {tot:g}, not 1")
            cash = co2 = hours = eff = 0.0
            notes, parts = [], []
            for nm, sh in shares.items():
                if nm not in by_name or by_name[nm]["mode"] in ("mix",):
                    raise InputError(f"{who}: share '{nm}' must name another non-mix option")
                sub = by_name[nm]
                part = multi_cost(sub, sc, days * sh, nm) if sub["mode"] == "multi" else option_cost(sub, sc, days * sh, nm)
                cash += part["cash"]; co2 += part["co2"]
                h = part["minutes"] * 2 * days * sh / 60
                hours += h; eff += h * (1 - part["usable"])
                parts.append(f"{nm} {sh * 100:.0f} % of days ({days * sh / 12:.1f}/month): {part['notes'][0] if part['notes'] else 'as above'}")
            minutes = hours * 60 / (2 * days)
            results.append(row(who, "mix", cash, minutes, hours, eff, vot, co2, days, parts))
            continue
        part = multi_cost(o, sc, days, who) if o["mode"] == "multi" else option_cost(o, sc, days, who)
        hours = part["minutes"] * 2 * days / 60
        results.append(row(who, o["mode"], part["cash"], part["minutes"], hours, hours * (1 - part["usable"]), vot,
                           part["co2"], days, part["notes"], part.get("breakdown")))
    return {"currency": sc_raw.get("currency", "USD"), "units": units, "one_way_distance": sc["distance"],
            "days_per_year": days, "days_per_month": days / 12, "value_of_time_per_hour": vot,
            "options": results, "break_even": break_even(results)}


def multi_cost(o, sc, days, who):
    legs = o.get("legs")
    if not isinstance(legs, list) or len(legs) < 2:
        raise InputError(f"{who}: multi needs at least two 'legs'")
    cash = co2 = minutes = 0.0
    notes, usable_min = [], 0.0
    for j, leg in enumerate(legs, 1):
        if leg.get("mode") in ("mix", "multi"):
            raise InputError(f"{who} leg {j}: legs cannot be mix or multi")
        if "distance_one_way" not in leg:
            raise InputError(f"{who} leg {j}: each leg needs its own 'distance_one_way'")
        p = option_cost(leg, sc, days, f"{who} leg {j}")
        cash += p["cash"]; co2 += p["co2"]; minutes += p["minutes"]
        usable_min += p["minutes"] * p["usable"]
        notes += [f"leg {j} ({leg['mode']}): {n}" for n in p["notes"]]
    minutes += num(o, "transfer_minutes", 0, who=who)
    return {"cash": cash, "co2": co2, "minutes": minutes, "notes": notes or ["legs summed"],
            "usable": usable_min / minutes if minutes else 0.0}


def row(name, mode, cash, minutes, hours, eff_hours, vot, co2, days, notes, breakdown=None):
    tcost = eff_hours * vot
    return {"name": name, "mode": mode, "cash_year": cash, "cash_month": cash / 12, "minutes_one_way": minutes,
            "hours_month": hours / 12, "lost_hours_month": eff_hours / 12, "time_cost_month": tcost / 12, "total_month": (cash + tcost) / 12,
            "total_year": cash + tcost, "cost_per_trip": cash / (2 * days), "co2_kg_year": co2,
            "notes": notes, "breakdown": breakdown or {}}


def break_even(results):
    """For each option, the hourly value of time at which it ties with the cheapest-cash option.
    Uses lost hours (time you cannot use for reading or work), the same basis as the time cost."""
    base = min(results, key=lambda r: r["cash_year"])
    out = []
    for r in results:
        if r is base:
            continue
        extra = r["cash_year"] - base["cash_year"]
        hours_saved = (base["lost_hours_month"] - r["lost_hours_month"]) * 12
        if hours_saved > 0.01:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": extra / hours_saved})
        else:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": None})
    return out


def fmt_plain(v, sym="$"):
    return f"{sym}{v:,.0f}" if abs(v) >= 100 else f"{sym}{v:,.2f}"


def render(res):
    sym = SYMBOLS.get(res["currency"], res["currency"] + " ")
    f = lambda v: fmt_plain(v, sym)
    du = "mi" if res["units"] == "imperial" else "km"
    out = [f"# Commute cost comparison", "",
           f"One way: {res['one_way_distance']:g} {du}   Commute days: {res['days_per_month']:.1f}/month ({res['days_per_year']:.0f}/year)   "
           f"Value of time: {f(res['value_of_time_per_hour'])}/hour   Currency: {res['currency']}", "",
           "| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |",
           "|---|---|---|---|---|---|---|---|---|"]
    rs = res["options"]
    for r in sorted(rs, key=lambda r: r["total_month"]):
        out.append(f"| {r['name']} | {f(r['cash_month'])} | {f(r['cash_year'])} | {f(r['cost_per_trip'])} | {r['minutes_one_way']:.0f} min | "
                   f"{r['hours_month']:.1f} | {f(r['time_cost_month'])} | **{f(r['total_month'])}** | {r['co2_kg_year']:,.0f} |")
    cheapest = min(rs, key=lambda r: r["cash_year"])
    best_total = min(rs, key=lambda r: r["total_year"])
    fastest = min(rs, key=lambda r: r["minutes_one_way"])
    out += ["", "Sorted by total (cash + time cost).", "",
            "## Headline", "",
            f"- Cheapest in cash: **{cheapest['name']}** ({f(cheapest['cash_month'])}/month).",
            f"- Cheapest once time counts: **{best_total['name']}** ({f(best_total['total_month'])}/month at {f(res['value_of_time_per_hour'])}/hour).",
            f"- Fastest: **{fastest['name']}** ({fastest['minutes_one_way']:.0f} min one way, {fastest['hours_month']:.1f} h/month)."]
    out += ["", f"## Break-even value of time (vs {cheapest['name']}, the cheapest in cash)", "",
            "| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |", "|---|---|---|---|"]
    for b in res["break_even"]:
        if b["break_even_per_hour"] is None:
            verdict = "never on cost: it does not save usable time" if b["extra_cash_year"] > 0 else "always: same cash, no more lost time"
            hs = f"{b['hours_saved_year']:.0f}" if b["hours_saved_year"] >= 0 else f"none ({-b['hours_saved_year']:.0f} more lost)"
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {hs} | {verdict} |")
        else:
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {b['hours_saved_year']:.0f} | {f(b['break_even_per_hour'])}/hour |")
    out += ["", "## Notes per option", ""]
    for r in rs:
        bd = ", ".join(f"{LABELS.get(k, k)} {f(v / 12)}" for k, v in r["breakdown"].items() if v) if r["breakdown"] else ""
        out.append(f"- **{r['name']}** ({r['mode']}): " + "; ".join(([f"per month: {bd}"] if bd else []) + r["notes"]))
    out += ["", "Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). "
            "Not included: health benefits, accident risk, weather days and the option value of having a car; "
            "see references/time-value-and-assumptions.md."]
    return "\n".join(out) + "\n"


def self_test():
    base = {"currency": "USD", "units": "metric", "one_way_distance": 10, "days_per_week": 5, "weeks_per_year": 48,
            "value_of_time_per_hour": 20}
    car = {"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_l_per_100km": 8, "fuel_price": 1.5,
           "maintenance_per_distance": 0.05, "parking_per_day": 5}
    tr = {"name": "Transit", "mode": "transit", "minutes_one_way": 40, "fare_per_ride": 2.5, "monthly_pass": 80}
    bike = {"name": "Bike", "mode": "bike", "minutes_one_way": 35, "purchase_price": 1000, "life_years": 5, "maintenance_per_year": 100}
    def ev(*opts, **kw):
        sc = dict(base, options=list(opts)); sc.update(kw.pop("sc", {}))
        return evaluate(sc, **kw)
    def get(res, n):
        return next(r for r in res["options"] if r["name"] == n)
    cases = []
    def case(name, fn):
        try:
            ok, err = bool(fn()), ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))
    # car: 240 days * 20 km = 4800 km; fuel 384 L * 1.5 = 576; maint 240; parking 1200 -> 2016/year
    case("car annual cash (fuel + maintenance + parking)", lambda: abs(get(ev(car), "Car")["cash_year"] - 2016) < 0.01)
    case("car CO2 petrol 2.35 kg/L", lambda: abs(get(ev(car), "Car")["co2_kg_year"] - 384 * 2.35) < 0.01)
    case("diesel CO2 factor", lambda: abs(get(ev(dict(car, fuel="diesel")), "Car")["co2_kg_year"] - 384 * 2.68) < 0.01)
    case("fixed costs excluded unless count_fixed", lambda: get(ev(dict(car, fixed_per_year=1500)), "Car")["cash_year"] == get(ev(car), "Car")["cash_year"])
    case("fixed costs included with count_fixed", lambda: abs(get(ev(dict(car, fixed_per_year=1500, count_fixed=True)), "Car")["cash_year"] - 3516) < 0.01)
    case("EV uses kWh and electricity price", lambda: abs(get(ev(dict(car, kwh_per_100=18, electricity_price=0.3, fuel_l_per_100km=None)), "Car")["breakdown"]["energy"] - 4800 / 100 * 18 * 0.3) < 0.01)
    case("imperial mpg and $/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["cash_year"] - 4800 / 30 * 3.6) < 0.01)
    case("imperial CO2 8.89 kg/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["co2_kg_year"] - 160 * 8.89) < 0.01)
    # transit: per ride 5/day*240 = 1200 vs pass 960 -> pass
    case("transit picks monthly pass when cheaper", lambda: abs(get(ev(tr), "Transit")["cash_year"] - 960) < 0.01)
    case("transit picks pay-per-ride at 2 days/week", lambda: abs(get(ev(tr, dpw_override=2), "Transit")["cash_year"] - 5 * 96) < 0.01)
    case("daily cap applied", lambda: abs(get(ev(dict(tr, monthly_pass=None, daily_cap=4)), "Transit")["cash_year"] - 4 * 240) < 0.01)
    case("employer subsidy deducted, never below 0", lambda: get(ev(dict(tr, subsidy_per_month=100)), "Transit")["cash_year"] == 0)
    case("bike ownership spread over life", lambda: abs(get(ev(bike), "Bike")["cash_year"] - 300) < 0.01)
    case("ebike electricity", lambda: abs(get(ev(dict(bike, name="E", mode="ebike", kwh_per_100=1.5, electricity_price=0.3)), "E")["breakdown"]["energy"] - 4800 / 100 * 1.5 * 0.3) < 0.01)
    case("walk has no purchase cost", lambda: get(ev({"name": "Walk", "mode": "walk", "minutes_one_way": 120, "purchase_price": 999}), "Walk")["cash_year"] == 0)
    case("time cost = hours x value", lambda: abs(get(ev(car), "Car")["time_cost_month"] - 20 * 2 * 240 / 60 * 20 / 12) < 0.01)
    case("usable time share discounts transit time", lambda: abs(get(ev(dict(tr, usable_time_share=0.5)), "Transit")["time_cost_month"] - 40 * 2 * 240 / 60 * 20 * 0.5 / 12) < 0.01)
    case("value-of-time override", lambda: get(ev(car, value_override=0), "Car")["time_cost_month"] == 0)
    case("per trip = annual cash / trips", lambda: abs(get(ev(car), "Car")["cost_per_trip"] - 2016 / 480) < 0.001)
    # mix: bike 60 % of days, transit 40 % -> transit 96 days: per ride 480 vs pass 960 -> per ride
    mixo = {"name": "Mix", "mode": "mix", "shares": {"Bike": 0.6, "Transit": 0.4}}
    case("mix: bike ownership full + transit pay-per-ride on its days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["cash_year"] - (300 + 480)) < 0.01)
    case("mix: time weighted by days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["minutes_one_way"] - (0.6 * 35 + 0.4 * 40)) < 0.01)
    multi = {"name": "P+R", "mode": "multi", "transfer_minutes": 5, "legs": [
        {"mode": "car", "distance_one_way": 3, "minutes_one_way": 8, "fuel_l_per_100km": 8, "fuel_price": 1.5, "parking_per_day": 2},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 20, "fare_per_ride": 2.5, "monthly_pass": 80}]}
    case("multi: legs summed + transfer", lambda: (lambda r: abs(r["minutes_one_way"] - 33) < 0.01 and abs(r["cash_year"] - (1440 * 0.08 * 1.5 + 480 + 960)) < 0.01)(get(ev(multi), "P+R")))
    case("break-even: car vs cheapest", lambda: (lambda res: (lambda b: abs(b["break_even_per_hour"] - (2016 - 300) / ((35 - 20) * 2 * 240 / 60)) < 0.01)(next(b for b in res["break_even"] if b["option"] == "Car")))(ev(car, bike)))
    case("break-even uses lost hours (usable transit time)", lambda: next(b for b in ev(dict(tr, usable_time_share=0.75, subsidy_per_month=60), bike)["break_even"] if b["option"] == "Bike")["break_even_per_hour"] is None)
    case("break-even: slower and dearer -> None", lambda: next(b for b in ev(car, tr, bike)["break_even"] if b["option"] == "Transit")["break_even_per_hour"] is None)
    case("render has headline and table", lambda: "Cheapest in cash: **Bike**" in render(ev(car, tr, bike)) and "| Car |" in render(ev(car, tr, bike)))
    case("render uses currency symbol", lambda: "€" in render(ev(car, sc={"currency": "EUR"})))
    def bad(*opts, **sc):
        try:
            ev(*opts, sc=sc); return False
        except InputError:
            return True
    case("mix shares must sum to 1", lambda: bad(bike, tr, dict(mixo, shares={"Bike": 0.6, "Transit": 0.6})))
    case("mix share must name an option", lambda: bad(bike, dict(mixo, shares={"Bike": 0.5, "Train": 0.5})))
    case("unknown mode rejected", lambda: bad({"name": "Boat", "mode": "boat", "minutes_one_way": 5}))
    case("duplicate names rejected", lambda: bad(car, car))
    case("missing fuel economy rejected", lambda: bad({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_price": 1.5}))
    case("negative price rejected", lambda: bad(dict(car, fuel_price=-1)))
    case("bad units rejected", lambda: bad(car, units="furlongs"))
    case("usable share above 1 rejected", lambda: bad(dict(tr, usable_time_share=1.5)))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Compare commute options per month and year, including time value")
    ap.add_argument("scenario", nargs="?")
    ap.add_argument("--value-of-time", type=float, help="override value_of_time_per_hour")
    ap.add_argument("--days-per-week", type=float, help="override days_per_week (e.g. 3 for hybrid work)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.scenario:
        ap.error("give a scenario JSON file, or --self-test")
    try:
        with open(a.scenario, encoding="utf-8") as fh:
            sc = json.load(fh)
        if a.days_per_week is not None and not 0.5 <= a.days_per_week <= 7:
            raise InputError("--days-per-week must be 0.5..7")
        res = evaluate(sc, a.value_of_time, a.days_per_week)
    except json.JSONDecodeError as e:
        print(f"error: scenario is not valid JSON: {e}", file=sys.stderr)
        return 2
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else render(res), end="" if not a.json else "\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinanceBudgetingDecision Making+2
F@f
0
Previous4 / 4