Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay tile.
77
96%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
"""Classify calendar events into drive-planner transit buckets — the brain.
drive-planner's job is "for every in-person ground meeting, make sure a
traffic-aware drive block exists (or was deliberately skipped), and never
nag about one that doesn't need it." Every bug in LoMBot's `drive_planner`
(16 closed issues, see Epic #59 §5) was a *scan-classification* error that
produced either a nag (false positive) or a silent miss (false negative).
So the scan is the brain: get the bucketing right, bake in the scars, and
make the output auditable rather than silently dropping events.
This module is the deterministic core (per `coding-policy: script-
delegation` — classification is a pure function of known inputs). It takes
the events JSON from the wide-window calendar fetch plus the current time,
the skip-state, and the home address, and returns one `MeetingClass` per
event. It does NOT route, fetch, or write — routing needs live traffic
(`maps_client`) and happens downstream; the scan only flags *which* legs
need routing and what their deadlines are.
Buckets (Epic #59 §3, §5):
needs_decision An in-person meeting with no planner block and no skip
— propose drive/skip (outbound from home + return home).
bridge Tight gap to a DIFFERENT venue — a venue→venue leg, not
a home round-trip. `gap_seconds` is exposed so the
router can warn when drive_time > gap (lombot #14/#7).
back_to_back Tight gap to the SAME venue — you stay put, no transit
leg between the two (lombot #14/#7).
has_block A planner marker block already references this meeting.
"Handled" = ANY marker exists, not both directions
(lombot #50 — requiring both caused 6 duplicate blocks).
skipped The user said "skip" and the skip has not expired
(lombot #49 — never re-ask a live skip).
past start ≤ now (small tolerance) — never plan into the
past (lombot #28).
filtered Not a routable ground meeting: the operator declined it
(or it's cancelled), all-day, virtual location, the
planner's own block, or an unparseable / missing time.
Returned (not dropped) so the sweep can audit and clean
up — the meta-lesson is "no silent miss".
Lessons baked in (Epic #59 §5):
#50 has_block = ANY marker; idempotent — caller checks before insert.
#49 skips persist with expiry; virtual filtered at scan, never asked.
#28 past guard everywhere — filter start ≤ now, no past legs.
#14/#7 neighbour-aware: same venue+tight = back_to_back (no leg);
different venue+tight = bridge; expose gap for the drive>gap warn.
#37 normalize whitespace in `location` before it reaches routing.
#2/#40 return + bridge are first-class; mode inherits outbound (the
car is at the venue) — recorded on the leg for the router.
stdlib-only per `coding-policy: dependency-management` (Stdlib First).
Public API:
from scan import scan, MeetingClass, TransitLeg, ScanError
results = scan(
events, # list of Google Calendar event dicts
now=datetime.now(tz=...), # tz-aware "current" time
home_address="12 Example St, Sampleton, TN 37000",
skip_state={"evt_3": "2026-07-01T00:00:00+00:00"}, # id -> expiry
)
for r in results:
if r.bucket == "needs_decision":
... # ask drive/skip, route r.legs
CLI (the deterministic-operation-as-script contract, per `coding-policy:
script-delegation` / `file-hygiene`):
echo '{"now": "...", "home_address": "...", "events": [...]}' \\
| python scan.py
# stdout: {"results": [<MeetingClass dict>, ...]}; exit 0
# stderr: {"error": "..."} + non-zero exit on bad input
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta
# A meeting whose start is at or before `now` is in the past and is never
# planned (lombot #28). A small grace window keeps a meeting that started a
# minute ago — while the user is en route — out of the `past` bucket.
PAST_TOLERANCE = timedelta(minutes=5)
# Two consecutive meetings closer than this are "tight": not worth (or not
# possible) returning home between them. Same venue + tight = stay put
# (back_to_back); different venue + tight = drive straight across (bridge).
# A black-box constant (per `coding-policy: script-as-black-box`); callers
# override via `tight_gap_seconds=` and tests pin the boundary.
DEFAULT_TIGHT_GAP_SECONDS = 90 * 60
# The marker drive-planner stamps into the description of every block it
# creates, so it recognizes its own work (idempotency, lombot #50) and can
# attribute a block to the meeting it serves. Example:
# [drive-planner:meeting=evt_42:dir=outbound]
_MARKER_RE = re.compile(r"\[drive-planner:meeting=(?P<id>[^:\]]+):dir=(?P<dir>[^:\]]+)\]")
# Substrings that mark a `location` as a virtual meeting, not a place to
# drive to (lombot #49 — filter at scan, never ask). A URL anywhere in the
# location is treated as virtual: real venues are addresses, not links.
_VIRTUAL_MARKERS = (
"zoom.us",
"meet.google.com",
"teams.microsoft.com",
"teams.live.com",
"webex.com",
"http://",
"https://",
"online",
"virtual",
"phone call",
"google meet",
)
class ScanError(ValueError):
"""Raised on a malformed scan input the caller must fix.
A ValueError subclass — the fix is "pass a well-formed input" (a
tz-aware `now`, a real list of events), not "retry".
"""
@dataclass(frozen=True)
class TransitLeg:
"""One drive leg the scan says should exist for a meeting.
The scan computes the leg and its deadline; it does NOT compute drive
time (that needs live traffic, downstream). `gap_seconds` is populated
for bridge legs so the router can warn when drive_time > gap_seconds
(lombot #14/#7).
Fields:
direction: "outbound" (home/prior venue → meeting), "return"
(meeting → home), or "bridge" (prior venue → meeting, tight gap)
origin: where the leg starts (home address or the prior venue)
destination: where the leg ends (the meeting venue or home)
arrive_by: hard arrival deadline (meeting start) for legs that must
land before the meeting; None for a return leg
depart_after: earliest departure (meeting end) for a return leg;
None for arrival-anchored legs
gap_seconds: for a bridge, seconds between the prior meeting's end
and this meeting's start — the budget the drive must fit inside;
None for non-bridge legs
"""
direction: str
origin: str
destination: str
arrive_by: datetime | None = None
depart_after: datetime | None = None
gap_seconds: int | None = None
@dataclass(frozen=True)
class MeetingClass:
"""One event's classification and the transit work it implies.
Fields:
meeting_id: the source event id
summary: the event summary (for the user-facing drive/skip prompt)
bucket: one of needs_decision / bridge / back_to_back / has_block /
skipped / past / filtered
reason: a short, audit-friendly explanation of the bucket choice
location: whitespace-normalized venue (lombot #37); None when the
event has no usable location
start: parsed event start (tz-aware); None when unparseable / all-day
end: parsed event end (tz-aware); None when unparseable / all-day
legs: drive legs to create for this meeting (empty unless the bucket
is needs_decision / bridge / back_to_back)
present_directions: for has_block, the marker directions already on
the calendar (e.g. ["outbound"]) so the sweep can audit a missing
return without flipping the meeting back to needs_decision
(lombot #48/#50)
"""
meeting_id: str
summary: str
bucket: str
reason: str
location: str | None = None
start: datetime | None = None
end: datetime | None = None
legs: tuple[TransitLeg, ...] = ()
present_directions: tuple[str, ...] = ()
timezone: str | None = None # IANA tz of the meeting, for the block's CREATE
@dataclass
class _Event:
"""Internal parsed view of a raw Google Calendar event."""
raw_id: str
summary: str
location: str | None
start: datetime | None
end: datetime | None
all_day: bool
marker: tuple[str, str] | None # (served_meeting_id, direction) if a block
declined: bool # the operator's own RSVP is "declined"
cancelled: bool # event-level status == "cancelled"
timezone: str | None # IANA name from start.timeZone, for the block's CREATE
def _normalize_location(location: object) -> str | None:
"""Collapse all whitespace runs to single spaces and strip (lombot #37).
A multi-line `location` (venue name `\\n` street address) crashed
LoMBot's geocoder; normalizing here means every downstream router sees
one clean line. Returns None for an empty / whitespace-only location or
any non-string value (a malformed JSON event must not raise here).
"""
if not isinstance(location, str) or not location:
return None
collapsed = re.sub(r"\s+", " ", location).strip()
return collapsed or None
def _is_virtual(location: str | None) -> bool:
"""True when the (already-normalized) location is a virtual meeting."""
if not location:
return False
lowered = location.lower()
return any(marker in lowered for marker in _VIRTUAL_MARKERS)
def _parse_dt(block: dict | None) -> tuple[datetime | None, bool]:
"""Parse a Google Calendar start/end block.
Returns (datetime, all_day). A timed event carries `dateTime`
(ISO-8601 with offset); an all-day event carries `date` (no time) and
is never a drive target. Returns (None, False) for a missing / malformed
/ timezone-naive block so the caller can filter it as unparseable — a
naive datetime can't be compared to the tz-aware `now` without raising.
"""
if not isinstance(block, dict):
return None, False
if "date" in block and "dateTime" not in block:
return None, True
return _parse_iso(block.get("dateTime")), False
def _parse_iso(raw: object) -> datetime | None:
"""Parse an ISO-8601 / RFC3339 string into a tz-aware datetime, or None.
Normalizes a trailing `Z` to `+00:00` (RFC3339 UTC, which some sources
emit) and rejects a timezone-naive result: a naive datetime compared to
the tz-aware `now` raises TypeError, so it is "unparseable" for our
purposes, not a usable time.
"""
if not isinstance(raw, str):
return None
text = raw.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return None
return parsed
def _self_declined(attendees: object) -> bool:
"""True when the operator's own attendee entry RSVP'd `declined`.
Google Calendar marks the operator's row with `self: true` and carries
their response in `responseStatus`. A meeting you declined must never get a
drive block. Tentative / needsAction / accepted all still plan (you might
go). Tolerant of any malformed attendees shape — never raises.
"""
if not isinstance(attendees, list):
return False
for attendee in attendees:
if (
isinstance(attendee, dict)
and attendee.get("self") is True
and attendee.get("responseStatus") == "declined"
):
return True
return False
def _etc_zone(dt: datetime | None) -> str | None:
"""A fixed-offset `Etc/GMT±N` zone for a tz-aware datetime, or None.
POSIX `Etc/GMT` zones invert the sign (`-05:00` → `Etc/GMT+5`). Only
whole-hour offsets map; a non-whole-hour offset (e.g. +05:30) has no Etc
zone and yields None.
"""
offset = dt.utcoffset() if dt is not None else None
if offset is None:
return None
total_minutes = offset.total_seconds() / 60
if total_minutes % 60 != 0:
return None
inverted = -int(total_minutes // 60)
return "Etc/GMT" if inverted == 0 else f"Etc/GMT{inverted:+d}"
def _extract_timezone(block: object) -> str | None:
"""The IANA `timeZone` for a start/end block, with an offset fallback.
The live `GOOGLECALENDAR_CREATE_EVENT` needs a `timezone` argument or it
reads the block's wall-clock as UTC and the drive block lands hours off
(#83). Real Google events carry `timeZone` (e.g. "America/Chicago"); when a
block omits it but its `dateTime` carries an offset, fall back to a
fixed-offset `Etc/GMT±N` zone so the instant is still anchored. Returns None
only when neither is available.
"""
if not isinstance(block, dict):
return None
tz = block.get("timeZone")
if isinstance(tz, str) and tz:
return tz
parsed, _ = _parse_dt(block)
return _etc_zone(parsed)
def _parse_event(raw: object) -> _Event:
"""Adapt a raw Google Calendar event into an `_Event`, total over any JSON.
A non-dict element (or one with non-string fields) must never raise —
one malformed event in a wide-window fetch can't be allowed to abort the
whole sweep. A non-dict becomes an empty `_Event` with no times, which
the classifier then surfaces as `filtered` (reason "missing or
unparseable time"), never silently dropped.
"""
if not isinstance(raw, dict):
return _Event(
raw_id="",
summary="",
location=None,
start=None,
end=None,
all_day=False,
marker=None,
declined=False,
cancelled=False,
timezone=None,
)
raw_id = str(raw.get("id") or "")
summary = str(raw.get("summary") or "")
location = _normalize_location(raw.get("location"))
start, start_all_day = _parse_dt(raw.get("start"))
end, end_all_day = _parse_dt(raw.get("end"))
description = raw.get("description")
marker_match = _MARKER_RE.search(description) if isinstance(description, str) else None
marker = (marker_match["id"], marker_match["dir"]) if marker_match else None
return _Event(
raw_id=raw_id,
summary=summary,
location=location,
start=start,
end=end,
all_day=start_all_day or end_all_day,
marker=marker,
declined=_self_declined(raw.get("attendees")),
cancelled=raw.get("status") == "cancelled",
timezone=_extract_timezone(raw.get("start")),
)
def _skip_active(skip_state: dict[str, str], meeting_id: str, now: datetime) -> bool:
"""True when a non-expired skip exists for this meeting (lombot #49).
A malformed or expired expiry is treated as "no skip" — the meeting
re-enters needs_decision rather than being silently suppressed forever.
"""
expiry = _parse_iso(skip_state.get(meeting_id))
if expiry is None:
return False
return expiry > now
def _is_past(event: _Event, now: datetime) -> bool:
"""True when the event has already started (lombot #28), with a grace window."""
return event.start is not None and event.start <= now - PAST_TOLERANCE
def _is_routable_candidate(event: _Event, now: datetime) -> bool:
"""True when the event can act as a real-meeting neighbour for §5 #14/#7.
A routable candidate is a future, timed, in-person, non-block meeting
with a usable location. Excluding past meetings here is the fix for the
cross of lombot #28 and #14/#7: a stale same-venue meeting must not turn
a future meeting into back_to_back and strip its outbound-from-home leg.
`end` is required too, so a half-parsed event never skews a gap.
"""
return (
event.marker is None
and not event.all_day
and not event.declined
and not event.cancelled
and event.start is not None
and event.end is not None
and event.location is not None
and not _is_virtual(event.location)
and not _is_past(event, now)
)
def scan(
events: list[dict],
*,
now: datetime,
home_address: str,
skip_state: dict[str, str] | None = None,
tight_gap_seconds: int = DEFAULT_TIGHT_GAP_SECONDS,
) -> list[MeetingClass]:
"""Classify every event into a drive-planner bucket.
Pure and deterministic: same inputs → same output, no I/O. Returns one
`MeetingClass` per input event (nothing is silently dropped — filtered
events come back with bucket="filtered" and a reason so the sweep can
audit and clean up).
Args:
events: raw Google Calendar event dicts (id, summary, location,
start, end, description) from the wide-window fetch.
now: tz-aware current time. A naive datetime raises ScanError —
comparing it to tz-aware event times would be wrong, not just
an exception.
home_address: the drive origin/return for home-anchored legs.
skip_state: meeting_id → ISO-8601 expiry; an unexpired entry buckets
the meeting as skipped. Defaults to no skips.
tight_gap_seconds: gap at or below which two consecutive meetings
are "tight" (bridge / back_to_back). Defaults to
DEFAULT_TIGHT_GAP_SECONDS.
Returns:
list[MeetingClass] in the input order.
Raises:
ScanError: if `now` is naive, `home_address` is empty, or `events`
is not a list.
"""
if now.tzinfo is None:
raise ScanError(
"scan: `now` is timezone-naive — pass a tz-aware datetime "
"(datetime.now(tz=ZoneInfo(...))) so it compares to event times"
)
if not home_address:
raise ScanError(
"scan: `home_address` is empty — read it from the canonical "
"user_profile.md Addresses block (current_home)"
)
if not isinstance(events, list):
raise ScanError(f"scan: `events` must be a list, got {type(events).__name__}")
if skip_state is not None and not isinstance(skip_state, dict):
raise ScanError(
"scan: `skip_state` must be a mapping of meeting_id → ISO-8601 expiry, "
f"got {type(skip_state).__name__}"
)
skip_state = skip_state or {}
parsed = [_parse_event(raw) for raw in events]
# Pass 1: every meeting referenced by ANY planner marker is "handled"
# (lombot #50 — ANY marker, not both directions). Record which
# directions are already present so the sweep can audit completeness.
handled_directions: dict[str, list[str]] = {}
for event in parsed:
if event.marker is not None:
served_id, direction = event.marker
handled_directions.setdefault(served_id, []).append(direction)
# Pass 2: order the genuine ground meetings by start so each can read its
# neighbours (lombot #14/#7). Only routable candidates are linked — and
# crucially that EXCLUDES past meetings (lombot #28): an already-past
# same-venue meeting must never make a future meeting back_to_back and
# strip its outbound-from-home leg. A non-candidate still gets classified,
# it just can't act as a neighbour.
candidates = [event for event in parsed if _is_routable_candidate(event, now)]
candidates.sort(key=lambda e: e.start) # type: ignore[arg-type,return-value]
order = {id(event): index for index, event in enumerate(candidates)}
results: list[MeetingClass] = []
for event in parsed:
results.append(
_classify(
event,
now=now,
home_address=home_address,
skip_state=skip_state,
handled_directions=handled_directions,
candidates=candidates,
order=order,
tight_gap_seconds=tight_gap_seconds,
)
)
return results
def _make_class(
event: _Event,
bucket: str,
reason: str,
*,
legs: tuple[TransitLeg, ...] = (),
present_directions: tuple[str, ...] = (),
) -> MeetingClass:
"""Build a MeetingClass from an event, carrying its identity fields over."""
return MeetingClass(
meeting_id=event.raw_id,
summary=event.summary,
bucket=bucket,
reason=reason,
location=event.location,
start=event.start,
end=event.end,
legs=legs,
present_directions=present_directions,
timezone=event.timezone,
)
def _classify(
event: _Event,
*,
now: datetime,
home_address: str,
skip_state: dict[str, str],
handled_directions: dict[str, list[str]],
candidates: list[_Event],
order: dict[int, int],
tight_gap_seconds: int,
) -> MeetingClass:
"""Assign one event to a bucket. Precedence matters — see inline order."""
# 1. The planner's own blocks are never meetings to plan (filtered), but
# they are how Pass 1 learned what is handled.
if event.marker is not None:
return _make_class(event, "filtered", "planner block")
# 2. Declined / cancelled — never plan a drive to a meeting you said no to
# (or one the organizer cancelled). This wins over every routable bucket.
if event.declined:
return _make_class(event, "filtered", "operator declined the meeting")
if event.cancelled:
return _make_class(event, "filtered", "event cancelled")
# 3. All-day events have no drive deadline.
if event.all_day:
return _make_class(event, "filtered", "all-day event")
# 4. Unparseable / missing time — can't anchor a leg; surface, don't drop.
if event.start is None or event.end is None:
return _make_class(event, "filtered", "missing or unparseable time")
# 4. Virtual / no location — never ask (lombot #49).
if event.location is None:
return _make_class(event, "filtered", "no location")
if _is_virtual(event.location):
return _make_class(event, "filtered", "virtual location")
# 5. Past guard (lombot #28) — never plan into the past.
if _is_past(event, now):
return _make_class(event, "past", "meeting already started")
# 6. Already handled — ANY marker counts (lombot #50). Wins over
# needs_decision so the planner never re-asks or double-books.
if event.raw_id in handled_directions:
present = tuple(dict.fromkeys(handled_directions[event.raw_id]))
return _make_class(
event,
"has_block",
f"planner block(s) present: {', '.join(present)}",
present_directions=present,
)
# 7. Live skip (lombot #49) — the user said no; don't ask again.
if _skip_active(skip_state, event.raw_id, now):
return _make_class(event, "skipped", "user-skipped, not expired")
# 8. A routable meeting — read neighbours and emit legs.
return _classify_transit(
event,
home_address=home_address,
candidates=candidates,
order=order,
tight_gap_seconds=tight_gap_seconds,
)
def _classify_transit(
event: _Event,
*,
home_address: str,
candidates: list[_Event],
order: dict[int, int],
tight_gap_seconds: int,
) -> MeetingClass:
"""Neighbour-aware leg computation (lombot #14/#7, #2/#40).
Outbound: skipped when the previous meeting is the SAME venue with a
tight gap (back_to_back — you're already there); a bridge when the
previous meeting is a DIFFERENT venue with a tight gap; otherwise a
home→venue leg. Return is the mirror on the next meeting. Anchoring
outbound on the first of a same-venue run and return on the last falls
out of this naturally.
"""
index = order[id(event)]
prev_event = candidates[index - 1] if index > 0 else None
next_event = candidates[index + 1] if index + 1 < len(candidates) else None
legs: list[TransitLeg] = []
is_bridge = False
is_back_to_back = False
# --- inbound side: how do we get TO this meeting? ---
prev_gap = _gap_seconds(prev_event, event)
if prev_event is not None and prev_gap is not None and prev_gap <= tight_gap_seconds:
if _same_venue(prev_event.location, event.location):
# Same venue, tight gap: you never left — no inbound leg.
is_back_to_back = True
else:
# Different venue, tight gap: drive straight across, not via home.
is_bridge = True
legs.append(
TransitLeg(
direction="bridge",
origin=prev_event.location or home_address,
destination=event.location or home_address,
arrive_by=event.start,
gap_seconds=prev_gap,
)
)
else:
legs.append(
TransitLeg(
direction="outbound",
origin=home_address,
destination=event.location or home_address,
arrive_by=event.start,
)
)
# --- return side: how do we get home AFTER this meeting? ---
# A tight gap to ANY next meeting cancels the return-home leg: same
# venue means you stay put, different venue means the next meeting owns
# the bridge leg in (lombot #14/#7) — either way you don't drive home.
next_gap = _gap_seconds(event, next_event)
skip_return = next_event is not None and next_gap is not None and next_gap <= tight_gap_seconds
if not skip_return:
legs.append(
TransitLeg(
direction="return",
origin=event.location or home_address,
destination=home_address,
depart_after=event.end,
)
)
if is_bridge:
bucket, reason = "bridge", "tight gap to a different venue"
elif is_back_to_back:
bucket, reason = "back_to_back", "tight gap to the same venue"
else:
bucket, reason = "needs_decision", "standalone in-person meeting"
return _make_class(event, bucket, reason, legs=tuple(legs))
def _gap_seconds(earlier: _Event | None, later: _Event | None) -> int | None:
"""Seconds between `earlier.end` and `later.start`; None if either is missing."""
if earlier is None or later is None or earlier.end is None or later.start is None:
return None
return int((later.start - earlier.end).total_seconds())
def _same_venue(a: str | None, b: str | None) -> bool:
"""Case-insensitive equality of two normalized venue strings.
Both are already whitespace-normalized (lombot #37); equality on the
cleaned string is enough to tell "same place" from "different place"
without geocoding (which is downstream and live).
"""
if a is None or b is None:
return False
return a.casefold() == b.casefold()
def actionable(results: list[MeetingClass]) -> list[MeetingClass]:
"""Filter to the buckets that need the planner to do something now.
needs_decision / bridge / back_to_back are the buckets that produce a
drive/skip prompt and new blocks. has_block / skipped / past / filtered
are terminal for this sweep (the sweep audits them separately).
"""
actionable_buckets = {"needs_decision", "bridge", "back_to_back"}
return [r for r in results if r.bucket in actionable_buckets]
def _leg_to_dict(leg: TransitLeg) -> dict:
"""JSON-serializable view of a TransitLeg (datetimes → ISO-8601)."""
return {
"direction": leg.direction,
"origin": leg.origin,
"destination": leg.destination,
"arrive_by": leg.arrive_by.isoformat() if leg.arrive_by else None,
"depart_after": leg.depart_after.isoformat() if leg.depart_after else None,
"gap_seconds": leg.gap_seconds,
}
def _class_to_dict(result: MeetingClass) -> dict:
"""JSON-serializable view of a MeetingClass (datetimes → ISO-8601)."""
return {
"meeting_id": result.meeting_id,
"summary": result.summary,
"bucket": result.bucket,
"reason": result.reason,
"location": result.location,
"start": result.start.isoformat() if result.start else None,
"end": result.end.isoformat() if result.end else None,
"legs": [_leg_to_dict(leg) for leg in result.legs],
"present_directions": list(result.present_directions),
"timezone": result.timezone,
}
def main() -> int:
"""CLI wrapper around the pure `scan()` — the script process contract.
stdin: a JSON object
{"events": [<gcal event>, ...], "now": "<tz-aware ISO-8601>",
"home_address": "...", "skip_state": {<id>: "<ISO expiry>"},
"tight_gap_seconds": <int, optional>}
stdout: {"results": [<MeetingClass dict>, ...]} (exit 0)
stderr: {"error": "..."} with a non-zero exit on invalid JSON, a missing
or timezone-naive `now`, an empty `home_address`, or any ScanError.
The pure `scan()` stays importable for unit tests; this is the
deterministic-operation-as-script surface per `coding-policy:
script-delegation` / `file-hygiene`.
"""
try:
request = json.load(sys.stdin)
except json.JSONDecodeError as exc:
print(json.dumps({"error": f"invalid JSON on stdin: {exc}"}), file=sys.stderr)
return 1
if not isinstance(request, dict):
print(json.dumps({"error": "stdin must be a JSON object"}), file=sys.stderr)
return 1
now = _parse_iso(request.get("now"))
if now is None:
print(
json.dumps(
{
"error": "`now` must be a timezone-aware ISO-8601 string "
f"(got {request.get('now')!r})"
}
),
file=sys.stderr,
)
return 1
tight_gap = request.get("tight_gap_seconds", DEFAULT_TIGHT_GAP_SECONDS)
# bool is an int subclass — exclude it so `true`/`false` is not a "gap".
if not isinstance(tight_gap, int) or isinstance(tight_gap, bool) or tight_gap <= 0:
print(
json.dumps(
{"error": f"`tight_gap_seconds` must be a positive integer (got {tight_gap!r})"}
),
file=sys.stderr,
)
return 1
try:
results = scan(
request.get("events", []),
now=now,
home_address=request.get("home_address", ""),
skip_state=request.get("skip_state"),
tight_gap_seconds=tight_gap,
)
except ScanError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
print(json.dumps({"results": [_class_to_dict(result) for result in results]}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())skills
check-travel-bookings
drive-planner
drive-planner-recheck
flight-assist
nightly-travel-sync
sync-tripit