Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), 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
maps_client (jbaruch/nanoclaw-travel#59)maps_client gained a Google-primary → TomTom-backup chain behind the unchanged travel_time() → TravelTime interface, the first piece of the drive-planner epic (#59) and a hardening of the existing flight time_to_leave. Google Distance Matrix stays primary; on any Google MapsError or transport (URLError) failure, the client falls back to TomTom when TOMTOM_API_KEY is configured. TomTom routing is coordinates-only, so the new TomTomClient does geocode-origin → geocode-destination → route-with-traffic=true, mapping noTrafficTravelTimeInSeconds / travelTimeInSeconds onto the same free-flow / in-traffic split Google returns. TravelTime gained a source field ("google" / "tomtom") so callers can tell which provider answered. There is deliberately no no-traffic fallback (e.g. OSRM) — a duration without a live-traffic model is false confidence for a leave-by deadline; when both providers fail the client raises MapsError("ALL_PROVIDERS_FAILED", …) naming what each reported. MapsClient.from_env wires the backup only when TOMTOM_API_KEY is set, so a Google-only deploy is unchanged. The caller in precheck.py already catches MapsError + URLError, so the fallback integrates with no caller change. 16 new tests (TomTom geocode+route success, no-baseline traffic split, geocode/route zero-results, full Google→TomTom fallback on both MapsError and URLError, combined-failure error, Google-success-skips-TomTom, from_env wiring with/without the key); .env.example documents the optional key.
jbaruch/nanoclaw-flight-assist#55)The final reconciliation slice: switched-away flights now get their managed calendar events torn down, and the reconcile runs on the wake cycle. calendar_reconcile.run_reconcile gained a second pass — a tombstone sweep over on-disk flights that have dropped out of active-flights.json but still carry a calendar_events ledger. The per-flight wake loop only visits active flights, so this sweep is the only place a switched-away flight's stale events (which byAir leaves behind) get deleted. It resolves each tombstone's disposition off the retained ledger (switched_away / cancelled / diverted → teardown deletes; completed → leave the events as a historical record), executes the deletes, then archives (removes) the state file once teardown settles — every delete succeeded, or the flight has completed. A failed delete keeps its ledger entry, so the tombstone is retained for the next cycle's retry rather than archived with events still live. The summary gains an archived count. Teardown is ledger-driven, so when there are no active flights the cycle skips the calendar fetch entirely.
For the tombstone to survive, sync_tripit._reconcile_active_flights now retains flight-<id>.json (instead of deleting it on upstream removal) when the record still holds a non-empty calendar_events ledger; a removed flight with nothing to tear down is still deleted immediately. state.py gained list_flight_state_ids() to enumerate on-disk per-flight files regardless of active-flights membership — the sweep needs to see exactly the flights the index no longer lists.
SKILL.md Step 3 became "Handle the precheck wake cycle": it runs scripts/reconcile.py first (idempotent, delta-only, safe alongside byAir's own delay-shifts), then composes the notification. no_calendar / no_flights / missing-Composio-credentials all mean reconciliation is inactive this cycle and are handled silently — calendar reconciliation stays optional. 11 new tests (sweep teardown + archival, completed-leaves-events, failed-delete retention, empty-ledger non-tombstone, active+tombstone in one cycle, list_flight_state_ids, sync_tripit tombstone retention vs immediate delete).
jbaruch/nanoclaw-flight-assist#55)The I/O layer that connects the pure planner to live Google Calendars (#55). calendar_reconcile.py resolves the calendar IDs, fetches + normalizes the current calendar state via Composio, builds the per-flight planner inputs (disposition via disposition.py, boarding lead via boarding_lead.py, byAir-truth dep/arr times), runs plan_reconciliation, executes the returned ops (create / update / adopt / delete / forget) through composio_client, and writes the owned event IDs back into each flight's calendar_events ledger. scripts/reconcile.py is the wake-cycle entry point: it emits a single-line JSON summary (status ∈ ok / no_calendar / no_flights) and collects per-op Composio failures rather than aborting the cycle — a delete that 404s is an idempotent success, a real failure defers that op to the next cycle.
The flight ("Flighty Flights") calendar ID is resolved at runtime from the operator-supplied byair_calendar_name and cached, never hardcoded in tile code per rules/flight-data-locality.md; Reclaim travel blocks live on the primary calendar (content-classified). The exact GOOGLECALENDAR_* argument field names are isolated in one section for live-toolkit verification, the same treatment composio_client.py gives its action slugs. This slice reconciles the flights in active-flights.json; the tombstone sweep for switched-away flights lands next (the planner already emits teardown ops for cancelled / diverted flights still in the index, which this executes). 18 orchestration tests against a fake Composio client (resolution, create/adopt/teardown, delta no-op, 404 idempotency, Reclaim same-airport-gap delete, malformed-event skipping) plus 2 CLI-contract tests.
jbaruch/nanoclaw-flight-assist#55)config.json gains two optional calendar-reconcile fields: byair_calendar_name (operator-supplied display name of the flight calendar) and byair_calendar_id (the id the reconcile caches after its first name match). Both are optional and absent-tolerant, so the v3→v4 owner-side migration only bumps schema_version — no shape change to config, active-flights, or per-flight records. See state-schema.md.
jbaruch/nanoclaw-flight-assist#55)precheck._build_flight_state rebuilt the per-flight record from scratch on every poll and dropped calendar_events, which would have wiped the reconcile-owned ledger (and the teardown tombstone it doubles as) every ~2 minutes. It now carries the ledger forward verbatim from prior state, so the reconcile's writes survive subsequent polls.
jbaruch/nanoclaw-flight-assist#55)The read-side adapter for calendar reconciliation (#55), built against the real Google Calendar event shapes. calendar_normalize.py flattens a Google event resource into the planner's {event_id, calendar_id, summary, start, end, private_props, is_reclaim_travel} shape, and classifies Reclaim-generated travel blocks.
is_reclaim_travel is content-based, not calendar-based: there is no dedicated Reclaim calendar — Reclaim writes its travel blocks onto the user's primary calendar interleaved with real meetings, so the only safe delete discriminator is the event's own content. Two factors, both required: the Reclaim authorship signature (app.reclaim.ai) in the description AND a travel marker in the summary (🚌 Travel). Reclaim's habit/focus/task blocks carry the signature but a different summary → not flagged; a user's own event titled "Travel" carries no signature → not flagged. The planner further bounds every delete to a same-airport layover gap, so a genuine meeting is never a candidate. calendar_id comes from the fetch context (authoritative), not the event body; private_props is extendedProperties.private.
jbaruch/nanoclaw-flight-assist#55)Real Flighty flight-event summaries render the code with a space (✈ BNA→YYZ • UA 8018) while byAir's code field may carry it unspaced (UA8018), so the planner's code in summary adopt-match missed. _match_byair_event now strips whitespace from both sides before comparing, matching regardless of which side carries the space.
jbaruch/nanoclaw-flight-assist#55)Next deterministic slice of calendar reconciliation (#55): disposition.py resolves each flight's reconciliation disposition (active / cancelled / diverted / switched_away / completed) that plan_reconciliation consumes to decide between normal reconcile, teardown, and leave-as-record. The computation needs the two inputs the pure planner deliberately stays out of — the wall clock and active-flights.json membership — so it lives in one isolated, tested module, the same carve-out as boarding_lead.py keeping volatile policy out of the planner.
Precedence: byAir computed_status cancelled/diverted wins over membership and time; landed or an effective-arrival instant at/before now is completed; a flight that has dropped out of active-flights while still in the future is switched_away (the per-flight wake loop can no longer see it — teardown runs off the retained ledger tombstone); everything else in active-flights and not yet arrived is active. Effective arrival prefers byAir's actual last_snapshot.arr_time over scheduled_arr_time, so a delayed in-air flight stays active until it actually lands. 16 tests cover the precedence matrix, the actual-vs-scheduled arrival boundary, null/missing snapshots, and the RFC-3339 offset handling.
jbaruch/nanoclaw-flight-assist#55)The I/O layer the pure planner (#55, 0.1.31) needs to execute its op list. composio_client.py is a thin stdlib-urllib REST client over Composio's v3 tools/execute/{action} endpoint, mirroring byair_client.py / maps_client.py (HTTP-mockable in CI, one client per process). It injects x-api-key auth + COMPOSIO_USER_ID scoping, names the GOOGLECALENDAR_* action slug, and passes a Composio-shaped arguments dict through — the planner-op → arguments mapping (and the version-specific per-action argument schemas) stays with the reconcile executor that lands next, where it is verified against the live toolkit.
The Composio envelope returns HTTP 200 even on a tool-level failure (successful: false), so the client raises ComposioError on that and surfaces the upstream provider status in .status_code — a delete that 404s (event already gone) is distinguishable from a real failure, letting the executor treat it as an idempotent no-op. HTTP-level failures (bad key, 5xx) propagate as urllib.error.HTTPError; a body-read timeout normalizes to URLError (mirrors byair, #28). 15 HTTP-mocked tests cover request shaping, the success/failure envelope, status-code surfacing, and transport-error normalization.
check-env.py now also reports composio_key_present / composio_user_present (SKILL.md Step 1 + tests updated to match), and .env.example documents COMPOSIO_API_KEY / COMPOSIO_USER_ID (plus the optional COMPOSIO_BASE_URL override). No wake-cycle wiring yet — the reconcile script that fetches events, runs the planner, and writes the ledger back lands in the follow-up.
jbaruch/nanoclaw-flight-assist#55)The deterministic core of calendar-event reconciliation (#55), built as two pure, network-free modules so the whole decision surface unit-tests in CI per coding-policy: script-delegation. The Composio I/O layer that executes the plan lands in a follow-up.
calendar_plan.py — plan_reconciliation(flights, events, config) takes the per-flight calendar_events ledger plus a normalized snapshot of what is on the byAir and Reclaim calendars, and emits a declarative op list (create/update/delete/adopt/forget) that converges the calendar to the desired state. Delta-only: it no-ops when a live event already matches the synced_signature, so it is safe to run alongside byAir's own shifts (no stomping). Covers all four behaviors: boarding-block lifecycle, byAir flight-event adopt-by-tag-then-shift, the positional Reclaim same-airport-layover deletion rule, and teardown of managed events on a cancelled/diverted/switched flight. Event classification is by calendar ID (no summary regex), so user-created events are never touched and only Reclaim-calendar blocks in a same-airport gap are deleted.
boarding_lead.py — resolve_boarding_lead_minutes(...) encodes the (volatile) boarding-pace policy in one isolated, tested place; the planner consumes the resolved integer only. Policy: transoceanic crossing → 50, widebody → 50, narrowbody → 30, nothing classifiable → 30. Aircraft size is by aisle count (A320 family incl. A321, all 737, 757, regional/turboprop are narrowbody; twin-aisle is widebody), from byAir's top-level model with a fallback to inbound.aircraft_model. Transoceanic (TATL/TPAC) detection is a longitude-block + great-circle-distance heuristic over airport lat/lon — no country/continent table — and correctly excludes Europe↔Asia overland long-haul.
36 new tests (test_calendar_plan.py, test_boarding_lead.py) cover boarding create/no-op/shift/recreate, adopt/skip-tagged/tolerance/shift/forget, Reclaim delete-vs-keep across the positional cases, teardown, and the full lead-policy matrix on real airport coordinates. Also renames the Flighty references the v3 state-schema docs introduced to byAir per rules/flight-data-locality.md (byAir is the tile's single anonymized flight upstream — it both serves the data API and writes the flight events to the writable calendar); the boarding/flight calendar ID is operator config resolved at runtime, not hardcoded.
jbaruch/nanoclaw-flight-assist → jbaruch/nanoclaw-travelThe tile is broadening from flight-only notifications into a general travel assistant — ground-transit drive planning (borrowed from the ligolnik/lombot drive_planner design) lands as a sibling skill next. Repo and tessl registry identity rename to jbaruch/nanoclaw-travel; consumers update their additionalTiles entry to the new name. Historical CHANGELOG issue references keep the old nanoclaw-flight-assist#NN form — GitHub redirects them after the repo rename.
calendar_events ledger + state schema v3 (jbaruch/nanoclaw-flight-assist#55)Foundation for calendar-event reconciliation (#55): flight-assist is moving from a notification-only tile to one that writes Google Calendar events (a flight-assist-created boarding block, adopted byAir flight events, Reclaim travel-block cleanup). To update and delete those events in O(1) across the */2 precheck cadence — and to tear them down after a flight drops out of active-flights.json, where the per-flight wake loop can no longer see it — the per-flight state record needs a ledger of the event IDs flight-assist owns.
STATE_SCHEMA_VERSION bumps 2 → 3. Per-flight flight-<id>.json gains an optional calendar_events map keyed by event kind (boarding, flight); each entry carries event_id, calendar_id, managed (created/adopted), and a synced_signature (<start>/<end>) the planner diffs against to no-op when the live event already matches byAir truth. state.py validates the field structurally (object) only — the per-entry shape is owned and deep-validated by the reconcile planner that lands in a follow-up, the same split as last_snapshot ↔ byair_client. _migrate now chains its version steps (a v1 record runs v1→v2→v3 in one owner-side read), adding calendar_events: {} to per-flight records on the v2→v3 step and bumping config/active-flights with no shape change. New test_state.py cases cover the v2→v3 per-flight add, the config/active-flights version-only bump, the chained v1→v3 path, round-trip with calendar_events present, and structural rejection of a non-object value. No behavior change yet — the precheck and SKILL surfaces are untouched; this is the state contract the reconciler builds on.
boarding_started no longer trusts byAir's premature boarding label (jbaruch/nanoclaw-flight-assist#54)byAir flips computed_status to boarding up to ~1h before boarding actually starts, while its own computed_status_detail still reads "Boarding starts in N min" and computed_phase_progress is 0 — an internally contradictory payload (DL4662 fired a false "boarding now" alert twice, 2026-06-13 and 2026-06-16, while the flight was delayed 67 min and boarding had not begun). detect_wake_events no longer fires on the computed_status label alone: a new _is_real_boarding helper requires the boarding status AND a computed_status_detail that is not a future-tense "Boarding starts in …" countdown. The boarding transition is now computed against this real-boarding signal on both the prior and current snapshots, so a flight byAir prematurely marked boarding still fires once the detail flips to genuine boarding — even though the raw computed_status never changes across that flip. The upstream contradiction is byAir's (operator filed a support ticket 2026-06-16); this is the skill-side guard. Four new test_wake_rules.py cases cover premature-label suppression, the deferred real-boarding fire, a genuine non-future-detail boarding, and first-cycle premature suppression.
agentModel: tier-down (jbaruch/nanoclaw#613)Pin cadence-skill models via agentModel: frontmatter so they stop defaulting to Opus: Sonnet (claude-sonnet-4-6) for flight-assist — itinerary/flight reasoning matters there; Haiku (claude-haiku-4-5-20251001) for the data-sync skills nightly-travel-sync and sync-tripit. Part of the #613 Claude tier-down.
nightly-travel-sync ran-marker carries the <slot_key> date the #581 watchdog expects (jbaruch/nanoclaw-flight-assist#51)The skill's final-turn marker emitted nightly-travel-sync ran: clean/: surfaced with no date slot, so the #581 silent-success watchdog — which parses task_run_logs.result for ran <YYYY-MM-DD>: — classified a healthy run as EMPTY (FRESH) instead of PASS. The format only became observable after #45 fixed the underlying sync-tripit.sh failure that previously masked it. The marker now mirrors the sibling nightly-cfp-sync / nightly-order-sync template: nightly-travel-sync ran <slot_key>: clean (or : surfaced), where <slot_key> is today's UTC date in YYYY-MM-DD form. SKILL.md-only edit; no code change.
sync-tripit.sh, the host-op wrapper missed in the #318 migration (jbaruch/nanoclaw-flight-assist#45)The #299/#318 split moved nightly-travel-sync's three Python travel-source scripts into this tile (PR #42) but dropped sync-tripit.sh, the wrapper the mcp__nanoclaw__sync_tripit() host op resolves as <groupDir>/scripts/sync-tripit.sh. With no skill shipping it, fresh container spawns land without the file and the host op fails with sync-tripit.sh not found — surfaced in nightly-travel-sync Step 1, already broken in telegram_swarm (telegram_main only still worked off a stale Apr-27 copy a fresh spawn would lose). This adds the wrapper under skills/nightly-travel-sync/scripts/ — the bundle whose Step 1 invokes the op — cd-ing into the globally-installed reclaim-tripit-timezones-sync and running node sync.mjs sync --output=json under set -euo pipefail. The package is an orchestrator-image global (Dockerfile.orchestrator, jbaruch/nanoclaw) that a skill bundle can't declare itself, so the wrapper guards for it and exits with an actionable message naming the install site rather than a bare cd error when it's absent. Scripts ship with their skill dir, so no manifest edit. A smoke/contract test (tests/test_sync_tripit_script.py) locks the host-op contract: the script exists, is executable valid bash, runs under strict mode, invokes the sync entrypoint, and fails loudly when the package is missing.
wake_rules.py detection gaps: pre-existing schedule slip + inbound-delay retraction (jbaruch/nanoclaw-flight-assist#46, jbaruch/nanoclaw-flight-assist#48)Two symmetric blind spots in detect_wake_events, both leaving the operator with a stale read of a flight:
#46 — pre-existing schedule slip never fired delay. Delay detection was purely a delta between consecutive dep_time polls, so a delay already baked into the first snapshot never surfaced (KL1017 AMS→LHR sat at scheduled+31min across every poll with no prior dep_time to delta against; last_wake_at stayed null). detect_wake_events now takes the flight's scheduled_dep_time (a top-level state field, not part of the last_snapshot shape) and, on the first cycle only, fires a delay (with schedule_slip: True) when dep_time slips ≥ threshold past schedule. First-cycle-only gating means the persistent slip surfaces once and the delta rule owns every later shift, so it can't re-fire each poll. precheck.py resolves scheduled_dep_time before the wake-rule call and passes it through.
#48 — no event when an inbound-delay prediction walked back. inbound_delay_predicted fired on the way up but nothing fired on the way down, so after byAir escalated DL59's inbound to "connection missed, rebook now" and then retracted the prediction to null (both legs ultimately landed early), the last surface the operator saw for hours was "rebook now" — silence read as "still bad". A symmetric inbound_delay_retracted event now fires when a previously-surfaced prediction (≥ threshold) walks back below threshold or to null, carrying prev_delay_minutes/new_delay_minutes so the agent can compose an all-clear. Mutually exclusive with the prediction rule.
14 new test_wake_rules.py cases cover both: first-cycle slip at/above/below threshold, on-time, early, missing scheduled_dep_time, the persistent-slip no-re-fire guarantee; and retraction to null, below threshold, inbound-block-absent, partial-walk-back-still-above-threshold (no retraction), prior-below-threshold (nothing to retract), first-cycle, and prediction/retraction mutual exclusion.
jbaruch/nanoclaw-flight-assist#41)The #41 fix in refresh-travel-schedule.py (keep a past Check-in: whose matching Check-out: is still live, paired by trip-ID + hotel) shipped via the #318 extraction, but its four regression tests were dropped in transit — the fix landed uncovered in 0.1.22. This restores test_lodging_checkin_retained_while_stay_live, test_lodging_fully_past_stay_dropped, test_lodging_checkin_not_rescued_across_trips, and test_lodging_pairing_requires_trip_id, which lock the pairing behaviour against regression. No production-code change.
jbaruch/nanoclaw-admin#305)Companion to admin#305, which fixed maintenance surfaces (heartbeat, morning-brief) to phrase relative dates in the operator's timezone but left the flight-assist day_before surface — the one whose 2026-05-24 incident ("leg 1 today" at 21:36 the night before, container UTC already rolled to the next day) prompted the issue — to a separate fix. This is that fix.
New rules/operator-local-tz-phrasing.md (steering, alwaysApply) requires every relative-date word a flight-assist surface composes ("today" / "tomorrow" / "a travel day") to be labeled against the operator's local date. New skills/flight-assist/scripts/read-current-tz.py resolves current_tz from the host tz_state singleton at /workspace/store/messages.db (mounted RW in main/trusted containers). The overlay reads that store directly rather than admin's heartbeat-precheck.json, so it carries no nanoclaw-admin dependency; it fails open to available: false on any miss (missing DB/row, empty column, unsupported schema_version, unparseable zone) so a notification still fires with explicit-date phrasing. home_tz is deliberately not a fallback — relative-date phrasing needs where the operator is now.
Scope is narrow: only the today/tomorrow wording. Displayed flight clock times stay in the airport-local zone byAir provides (flight-data-locality / byAir's "show as-is, don't convert" contract) — the rule never converts a departure/arrival time. SKILL.md Step 3 routes the day_before and arrival/delay/time-to-leave surfaces through the rule. Unit tests cover the reader's resolve + every degrade path.
nightly-travel-sync bundle finishes the #299 reader-without-writer split (jbaruch/nanoclaw-admin#318)#299 moved check-travel-bookings (the reader of travel-db.json) into this tile but left the writers behind in nanoclaw-admin's nightly-external-sync bundle, so every chat loading the flight-assist overlay still required nanoclaw-admin just to refresh the data it consumes. This extracts the remaining travel-source scripts and the bundle steps that drive them into a flight-assist-owned skill.
New skills/nightly-travel-sync/:
SKILL.md — daily bundle (TripIt → Reclaim sync, refresh travel-schedule.json, two-tier Gmail freshness probe, rebuild travel-db.json, run check-travel-bookings). Independently scheduled via cadence:+script: frontmatter — it materialises its own scheduled_tasks row and no longer depends on the admin bundle or admin's resumable-cycle machinery. A step failure surfaces a note and finishes; the daily cron + freshness probe recover the next run.precheck.py — gates the wake to a 3-day cadence anchored on travel-db.json mtime (the bundle's terminal artifact, the file downstream consumers read). No separate cursor file, so the gate adds no self-owned state. Fails open (wake) on internal error so a transient stat error can't freeze the pipeline.scripts/refresh-travel-schedule.py, filter-tripit-bookings.py, check-travel-freshness.py — moved from nanoclaw-admin/skills/nightly-external-sync/scripts/, reformatted for this tile's ruff config (double quotes, bugbear B enabled — the ICS-field/datetime helpers were hoisted out of the parse loop to satisfy B023). The admin bundle's sync-tripit.sh was a zero-reference orphan that shelled out to an npm package present only in the orchestrator container, not the agent container; it was dropped rather than carried as dead code (Step 1 uses mcp__nanoclaw__sync_tripit, the IPC-integrated path the admin bundle already used).references/two-tier-probe.md was not carried over — as a loaded reference it was almost entirely rationale + restated filter behavior, which coding-policy: context-writing-style / script-as-black-box keep out of loaded artifacts. Its one executable directive ("never alert on travel-schedule.json mtime alone; escalate only on a stale status plus a matching TripIt forwarded-confirmation email") now lives inline in SKILL.md Step 3. Archived motivation: bare-mtime alerting was a false-positive engine — a stale travel-schedule.json usually just means no travel was booked recently (confirmed 2026-04-25, "Не, я просто давно не букал травел." — "I just haven't booked travel in a while"), which trained the operator to dismiss the channel. The classification detail the reference used to enumerate — TripIt Pro alerts, friend-shared trips, geofenced arrival marketing, and platform announcements are all excluded, only the forwarded-confirmation subject matches — lives solely in filter-tripit-bookings.py (PREFIX).The refresh-travel-schedule.py extracted here carries the #41 lodging fix (keep a past Check-in: whose matching Check-out: is still live, paired by trip-ID + hotel) plus its four regression tests, superseding the in-flight admin PR #317. Step 3's Gmail fallback discovers GMAIL_FETCH_EMAILS inline via COMPOSIO_SEARCH_TOOLS rather than depending on an admin steering rule, keeping the bundle self-contained. Tests + conftest fixtures (refresh_travel_schedule, filter_tripit_bookings, check_travel_freshness, nightly_travel_sync_precheck) moved alongside the scripts. travel-schedule.json / travel-db.json stay at /workspace/group/, so admin's cross-tile readers (check-orders, morning-brief) are unaffected.
jbaruch/nanoclaw#562)Follow-up to #36's wall-clock budget. execfile-error kills kept recurring at ~34s (2026-05-27, 2026-05-29) — surfaced again while tracing the heartbeat wake-storm in jbaruch/nanoclaw#562, because each transient flight-assist crash pins heartbeat's 24h task-failure window open. #36 set _CYCLE_POLL_HEADROOM_SECONDS = 10s, reserved before the 30s hard-kill for "one in-flight poll" — but it only counted the byAir poll (8s) and ignored the Maps travel_time query that _process_flight runs on top of it. _maybe_maps_client instantiated MapsClient.from_env() with its 10s default, so a flight started just under the budget ran byAir (8s) + Maps (10s) ≈ 18s and overran the kill.
The Maps client now takes the same bounded per-call timeout as byAir (_MAPS_CALL_TIMEOUT_SECONDS = 8.0), and _CYCLE_POLL_HEADROOM_SECONDS is derived from byair + maps + interpreter-teardown (20s, leaving a 10s start-budget) so the headroom is correct by construction if either timeout changes. Regression coverage: test_run_cycle_passes_bounded_per_call_timeout_to_maps_client pins the kwarg; test_poll_headroom_covers_byair_plus_maps_worst_case asserts the headroom ≥ byAir + Maps.
jbaruch/nanoclaw-flight-assist#38)Root-cause follow-up to #36. The live index tracked 25 active flights with departures spread out to ~44 days, all polled on the 30-min scheduled cadence; their last_polled_at values cluster, so large batches (e.g. 17 flights) come due in a single cycle and the sequential byAir polls are what race the 30s execFile kill. _due_for_poll now skips any flight whose seeded scheduled_dep_time is more than _POLL_HORIZON_HOURS = 24 away — it stays in active-flights.json (sync keeps the roster) but costs no byAir call until it crosses T-24h, at which point the first in-window poll fires day_before. The horizon clips nothing: T-24h is the earliest precheck event, and connection_risk already gates leg-1 on its own 24h lookahead and falls back to scheduled_arr_time for legs without a live snapshot, so horizon-skipped flights remain no-ops there. This shrinks the per-cycle poll batch at the source rather than only bounding it after the fact (#36's wall-clock budget remains the safety net). Regression coverage: test_poll_horizon_skips_flight_departing_beyond_24h, test_poll_horizon_polls_flight_just_inside_24h.
_run_cycle to a wall-clock budget so slow multi-flight cycles don't trip the 30s kill (jbaruch/nanoclaw-flight-assist#36)AyeAye flagged recurring precheck script failed: execfile-error on the tessl__flight-assist scheduled task (~5 fires over 4 days, each self-recovering next cycle), with every error row clustered at ~34–35s duration. Root cause: _run_cycle polls active flights sequentially, and #28 bounded each byAir call at 8s but not the cumulative total. With several active flights on slow upstreams the per-flight timeouts summed past the agent-runner's SCRIPT_TIMEOUT_MS = 30s execFile hard-kill (container/agent-runner/src/index.ts), killing the whole precheck — the observed ~34s being the 30s execFile timeout plus spawn/teardown.
_run_cycle now enforces an overall wall-clock budget (_SCRIPT_KILL_BUDGET_SECONDS - _CYCLE_POLL_HEADROOM_SECONDS — the 30s kill minus headroom for one in-flight poll plus interpreter startup/teardown). Before processing each flight it checks elapsed monotonic time; once the budget is reached it stops and defers the remaining flights to the next cycle, leaving their last_polled_at untouched so the cadence gate retries them — the same degraded-poll contract as the existing transient-transport branch. Deferred flights join the connection-risk exclusion set (removed_upstream_ids | poll_failed_ids | deferred_ids) because their snapshot wasn't verified this cycle. The budget clock is injected (monotonic=time.monotonic) so tests drive it deterministically without sleeping. Full per-flight concurrency (parallel byAir polls so total ≈ the slowest single call) would cut latency further but is a larger change, deferred as follow-up. Regression coverage: test_wall_clock_budget_defers_remaining_slow_flights, test_connection_risk_excludes_budget_deferred_flights.
jbaruch/nanoclaw-flight-assist#29)sync_tripit._run_sync called byair.list_trips(status="active") with no ownership argument, so the client default ownership="all" pulled friends' tracked trips into active-flights.json. The precheck then surfaced [M] wake events (delay, gate change, boarding) for flights the operator isn't on and can't act on — pure noise. The sync now requests ownership="mine", so friends' flights never enter the index. The request-side filter is authoritative; the per-flight ownership field in the response is unreliable (defaults to "mine" when byAir omits it). Regression coverage: test_sync_fetches_only_owned_trips.
Deploy note: on the first sync after this ships, friends' flights already in active-flights.json reconcile as removed and would emit tracked_flight_removed (surfaced per SKILL.md). Prune those entries from the NAS state at deploy time to avoid a one-time "stopped tracking" burst. On-demand lookup of a friend's flight is tracked separately (expose byAir as an MCP tool to the agent).
build_lodging_ranges no longer collapses repeat stays at one hotel (jbaruch/nanoclaw-flight-assist#24)check-travel-bookings.py:build_lodging_ranges keyed check-in / check-out dates in dicts by hotel name alone, so a trip that bookended the same hotel (stay → other cities → same hotel again) overwrote the first stay and produced at most one range per hotel — under-reporting lodging coverage and surfacing false uncovered nights. Per-hotel events are now replayed in date order with a check-out closing the most recently opened stay (LIFO); same-hotel stays don't overlap, so the open stay is the one a check-out belongs to. This keeps a stray earlier check-out from matching a later check-in and an orphan earlier check-in from stealing a later stay's check-out (both would misreport coverage). Orphan check-outs form no range; unmatched check-ins keep the existing 1-day fallback. Unique-per-hotel trips (the common path) are unaffected. Regression coverage: test_build_lodging_ranges_multiple_stays_same_hotel, test_build_lodging_ranges_same_hotel_extra_checkin_defaults_one_day, test_build_lodging_ranges_stray_earlier_checkout_not_consumed, test_build_lodging_ranges_orphan_earlier_checkin_not_stealing_later_stay.
jbaruch/nanoclaw-admin#310)check-travel-bookings.py's issue selector flagged any trip with transport and no lodging as "рейсы есть, отеля нет", including same-day round trips that need no overnight stay (Agentcon Miami: out + back on 2026-06-12, the return leg's arrival slipping to the next UTC day). The branch now treats a trip as needing no hotel only when the traveller is still in transit at the end of the trip window — the latest transport arrival within the trip reaches trip_end, as in a same-day round trip whose return slips past UTC midnight, or a red-eye that lands on the final day. When the latest arrival falls before trip_end the traveller has landed and is staying over. A missing hotel still surfaces in that case, including one-night single-leg trips and connecting outbounds, both of which classify_trip's has_future_transport guard leaves with empty uncovered_nights. The signal is arrival-vs-trip_end rather than raw leg count. Two same-direction legs (a connecting outbound) are not a round trip, and leg count alone would misclassify them as one. Regression coverage: test_classify_trip_same_day_round_trip_no_uncovered, test_main_same_day_trip_no_false_hotel_gap, test_main_one_night_single_leg_no_lodging_flagged, test_main_one_night_connecting_outbound_no_lodging_flagged, and test_main_multiday_single_transport_no_lodging_flagged. The skill's output-contract doc was updated to match.
ByAirClient per-call timeout in precheck to 8s (jbaruch/nanoclaw-flight-assist#28)precheck._run_cycle now instantiates ByAirClient.from_env(timeout=8.0) instead of relying on the default 30s. A single slow byAir response previously raced the 30s execFile budget in agent-runner and surfaced as precheck-error: execfile-error — killing the whole cycle and producing a task_run_logs status='error' row that pinned nanoclaw-admin heartbeat into system_health_issues wake mode for 24h. With the per-call timeout below the outer budget, slow upstream calls fall through the existing transient-transport branch in _run_cycle, which skips the affected flight for one cycle (cadence gate retries it next tick) and lets other flights' polls complete.
Companion change in ByAirClient._http_post: urlopen(..., timeout=X) wraps connect-side socket timeouts as urllib.error.URLError, but a timeout during response.read() of the body propagates raw TimeoutError (since socket.timeout is aliased to TimeoutError in Python 3.10+). The body-read path is now wrapped to normalize TimeoutError into URLError, so _run_cycle's transient-transport branch catches every transport timeout uniformly rather than letting body-read timeouts fall through to the outermost precheck_exception boundary and re-create the original cycle-kill symptom. Regression test: test_body_read_timeout_surfaces_as_urlerror.
_due_for_poll forces a poll when last_snapshot is None (jbaruch/nanoclaw-flight-assist#26)_due_for_poll now short-circuits to True when last_snapshot is None, so sync_tripit-seeded flights get polled on the next precheck tick instead of waiting up to a full cadence interval. Regression coverage: test_seeded_state_with_no_snapshot_forces_poll; two connection-risk tests updated to use a benign scheduled snapshot via the new _scheduled_snapshot helper.
check-travel-bookings migrated from nanoclaw-admin (jbaruch/nanoclaw-admin#299)Per-chat travel concerns now consolidate under nanoclaw-flight-assist: flight notifications, time-to-leave, connection risk, arrival logistics, and now booking-gap detection. Coherent domain, single tile, single co-load for affected chats.
Migration is structural. skills/check-travel-bookings/scripts/check-travel-bookings.py and skills/check-travel-bookings/scripts/build-travel-db.py carry across with these edits, all from review feedback during PR #22:
slug / item_count dead-variable cleanup in build-travel-db.py; explicit encoding="utf-8" on file opensbuild-travel-db.py now writes travel-db.json atomically (same-dir .tmp + os.replace) matching the _atomic_write_json pattern in skills/flight-assist/state.py — readers no longer see a half-written DB if the process is killed mid-writecheck-travel-bookings.py adds ensure_ascii=False on the error-JSON path so operator-facing diagnostic messages keep their non-ASCII punctuation intacttravel-db.json and travel-booking-state.json carry schema_version: 1 per coding-policy: stateful-artifacts. New state-schema.md sibling documents owner / writers / readers / migration policy for both artifacts. The writer (build-travel-db.py) stamps schema_version on every output; the reader (check-travel-bookings.py) gates on it with explicit branches for legacy-implicit-v1, forward-incompatible (> 1), and non-int values. Snooze entries in travel-booking-state.json carry the same per-record field. Nine new tests pin the contract: DB at v1 / missing / forward / non-int; snooze entries at v1 / legacy / forward / non-dict-corrupttests/conftest.py:_load asserts spec and spec.loader are non-None so fixture-loading misconfigs fail at _load time with an actionable message instead of a deeper AttributeErrorskills/check-travel-bookings/SKILL.md was restructured to follow skill-authoring's execution-mode preamble + flat numbered step format (policy reviewer feedback); content is preserved, and Step 3 now instructs the agent to stamp schema_version: 1 on snooze entries. The gaps[] example payload includes the uncovered_nights field the script actually emits.
Resolves the stateful-artifacts gap originally filed as #23 — that issue can close once this PR merges. The literal mount path /home/node/.claude/skills/tessl__check-travel-bookings/scripts/<file>.py used by nightly-external-sync Step 5 (build-travel-db.py) and morning-brief (check-travel-bookings.py) resolves to whichever tile owns the check-travel-bookings skill name — both consumers continue to work without code changes since the name doesn't change.
Tests follow: tests/test_check_travel_bookings.py and tests/test_build_travel_db.py migrated. New tests/conftest.py adds the two fixtures (check_travel_bookings, build_travel_db) ported from admin's conftest. Both scripts read/write /workspace/group/travel-db.json and /workspace/group/travel-booking-state.json; the writer chain (refresh-travel-schedule.py → build-travel-db.py) is unchanged from admin's perspective.
State plane note: existing /workspace/group/travel-db.json and /workspace/group/travel-booking-state.json carry across the migration as-is — they're group-scoped state files, not tile-shipped artifacts, so the deploy preserves operator-side snooze/resolve history.
OpenAI policy reviewer requested changes on two precondition violations in PR #21 (commit 5103c8f). Both addressed here:
Module-level catch-all in skills/sync-tripit/precheck.py broadens the error-handling outer-boundary carve-out. Removed the bootstrap try/except wrapping the cross-skill import. Path resolution + sys.path injection + the state import now live in a new _load_flight_assist() helper invoked from inside main()'s try block, so the sole catch-all sits at the outermost process boundary as the carve-out requires. The _load_flight_assist failure path is exercised by test_main_bootstrap_failure_emits_safe_json.
Non-owner reader could invoke owner-side migrations. sync-tripit's precheck previously called state.read_active_flights() / state.read_flight_state(), both of which silently invoke _migrate and rewrite the file on schema_version mismatch — a violation of the single-owner contract in coding-policy: stateful-artifacts. Added a migrate=True kwarg to _read_json_with_version and exposed two dedicated non-owner reader entry points: read_active_flights_snapshot() and read_flight_state_snapshot(flight_id). The snapshot readers treat any older schema_version as "no usable prior state" (return [] / None) and never write to disk; integrity failures (corrupt JSON, higher-than-current schema, missing required field at the current schema) still raise StateError. precheck.py now uses these snapshot readers. Owner-side flight-assist code paths are unchanged. state-schema.md documents the new reader contract.
Test coverage extended: gate tests now exercise the snapshot API; new test_should_sync_does_not_migrate_old_active_flights asserts the file's bytes + mtime are unchanged after a precheck run against a v1-schema state file; six new tests in tests/test_state.py cover the snapshot reader API (missing file, current payload, old-schema no-migrate, corruption, future-version, flight_id validation).
sync-tripit skill)sync_tripit.py shipped at v0.1.0 with the docstring claim "Run cadence: daily at ~04:00 local" but never had a cadence-registry entry — the orchestrator never invoked it, so active-flights.json was never populated, and the existing 2-min flight-assist precheck loop fired into an empty state file every cycle. This is the orchestration half of the two-bug stack diagnosed live 2026-05-22 (byAir HTTP 400 was the transport half, addressed in PR #20).
The naïve fix would be cadence: "0 4 * * *" on flight-assist's existing frontmatter — but flight-assist already declares a cadence: for its 2-min precheck, and each SKILL.md gets one cadence-registry row. Beyond the structural constraint, daily-at-04:00 doesn't match the access pattern: day-of-travel changes (delays, gate moves, cancellations) need responsive polling, while between-travel-window periods don't justify any byAir traffic. Per the operator-stated requirement, the cadence should be ~5 minutes when there's a flight in the next 24 hours, idle otherwise.
New skills/sync-tripit/ with cadence: "*/5 * * * *" + script: "precheck.py". The precheck implements an adaptive gate: any tracked flight with scheduled_dep_time in the next 24h triggers a byAir round-trip; active-flights.json mtime older than 6h triggers a sync (catches newly-booked trips landing between travel windows); no state file yet triggers cold-start; otherwise emit wake_agent: false with no byAir call. When the gate passes, the precheck delegates to flight-assist/sync_tripit.py via subprocess and forwards its stdout — sync_tripit.py already emits the {wake_agent, data} wake-payload contract this script needs, so composition lives in one place.
Cross-skill import: precheck.py reads state.py and locates sync_tripit.py via the runtime mount path /home/node/.claude/skills/tessl__flight-assist/ with a dev-clone-relative fallback (../flight-assist) for tests. Both skills ship from the same tile and are always co-deployed; if flight-assist is missing, the precheck raises FileNotFoundError at import time rather than silently failing.
Outer-boundary-process-contract handler in main() catches unexpected exceptions, emits safe-shape {"wake_agent": false, "data": {"reason": "precheck_internal_error"}}, exits 0 — the agent-runner reads non-zero exit OR invalid stdout JSON as wake_agent: false, which here would silently disable the entire flight-assist polling pipeline. Subprocess timeout (60s budget) surfaces as sync_subprocess_timeout; empty-stdout subprocess crashes surface as sync_no_output.
Adds tile.json entry for the new skill. Adds 13 mocked tests in tests/test_sync_tripit_precheck.py: gate correctness across cold-start / empty-recent / stale / imminent / out-of-window / past / multi-flight first-match / malformed-dep-time cases; subprocess delegation and forwarding; subprocess-timeout safe-shape conversion; outer-boundary exception handling. tessl skill review 87% (threshold 85). All pre-existing tests still pass.
Accept header missing text/event-stream (HTTP 400 on every call)byair_client.py set Accept: application/json on both _initialize and _tools_call outbound headers. The byAir MCP streamable-HTTP endpoint rejects with HTTP 400 — "Accept must contain both 'application/json' and 'text/event-stream'" because the MCP streamable-HTTP spec requires clients to advertise support for both response shapes (servers may stream tool responses via SSE). The _SessionExpired retry path doesn't engage on initialize because self._session_id is None at that point, so the original 400 propagated and the client could not complete the handshake. Result: every byAir call from a fresh process failed at the handshake, and sync_tripit.py could not populate active-flights.json. Combined with the orchestration gap leaving sync_tripit unscheduled, the precheck loop fired every 2 min, read an empty state file, and emitted wake_agent: false on every cycle — the skill was "installed but deaf" on flight days.
Two-line fix: Accept: "application/json" → Accept: "application/json, text/event-stream" at both header sites. Verified live against the production byAir endpoint 2026-05-22 — initialize returned HTTP 200 with a valid mcp-session-id. Q-value forms (application/json; q=1.0, text/event-stream; q=0.1) are NOT accepted by the byAir server — it does substring matching after splitting on , and the parameter-suffixed entries don't match the bare-token check; the verified-working form is the plain comma-separated list.
Defensive Content-Type guard added in _http_post: since the client now advertises text/event-stream, a server could pick SSE for some response. We don't parse SSE here (the operations this client uses — initialize, notifications/initialized, non-streaming tool calls — all return JSON in practice). The guard raises a clear ByAirError("unsupported_response_shape", ...) if Content-Type comes back as text/event-stream, rather than letting json.loads fail with a cryptic decoder error on event: / data: SSE prefixes.
Adds two regression tests in tests/test_byair_client.py: one asserts the outbound Accept header includes both content types on initialize, notification, and tool-call requests (via mocked urlopen per coding-policy: testing-standards); the other asserts the Content-Type guard raises the actionable error on a mocked SSE response. 15/15 byair_client tests pass.
The orchestration gap (no cadence-registry entry, no scheduled-task row for sync_tripit itself) lands separately — fixing the transport doesn't help if nothing ever invokes the feeder.
jbaruch/nanoclaw-flight-assist#17, #18)Two coupled bugs that left the skill installed-but-silent on a mobile traveller's flight days.
#17 — precheck.py never fired. The scheduled-task row that runs the precheck every 2 minutes is provisioned via the host orchestrator's cadence-registry (host repo src/cadence-registry.ts), which reads cadence: + script: from each installed skill's SKILL.md frontmatter on container spawn. flight-assist/SKILL.md carried no such declaration, so the registry walked past it and never created the row. Verified live 2026-05-20: no scheduled_tasks row matched any flight-assist / byair prompt despite the skill being fully installed. Add cadence: "*/2 * * * *" + script: "precheck.py" to the frontmatter; the cadence-registry's rebuild on the next container respawn provisions the row. The existing per-flight cadence ladder inside _interval_for still gates byAir calls per-flight, so the 2-min wake floor doesn't translate into 2-min byAir traffic.
#18 — time_to_leave resolved origin exclusively from config.json:home_address. A constant traveller (rarely at home on flight days) got either silent failure (no home base set) or structurally wrong notifications (home base set but user is 5000 km away). Add an origin-resolution ladder in precheck._resolve_time_to_leave_origin: (1) fresh /workspace/state/flight-assist/current-location.json snapshot (≤ 30 min old, formatted as "lat,lng" for Distance Matrix) → (2) home_address fallback → (3) None (skip the maps query when neither is available). The snapshot file is host-orchestrator-owned — flight-assist is a non-owner reader per coding-policy: stateful-artifacts, validates the documented shape, and returns None on any mismatch instead of raising. Without the orchestrator-side write the precheck behaves exactly as today (home_address-only); once the orchestrator's location-write companion lands the live ladder takes over. State-schema doc updated to describe the new file shape and reader contract.
skills/flight-assist/connection_risk.py — V1.1 cross-flight connection-risk detector (capability 4 of the V1 spec, previously deferred). Pure-function detector that groups on-disk per-flight state by trip_id, sorts each group by scheduled_dep_time, and walks consecutive (leg-1, leg-2) pairs where arr_airport_id(leg-1) == dep_airport_id(leg-2). Emits connection_at_risk events when the projected transfer window (scheduled_dep(leg-2) - projected_arr(leg-1), taking leg-1's live arr_time when populated and scheduled_arr_time as fallback) is below min_transfer_minutes (config-overridable, default 45). Suppression rules: leg-1 status in {landed, cancelled, diverted}, leg-1 scheduled departure > 24h away, connection_at_risk_fired already True on leg-2's marker. The event is keyed on leg-2's flight_id so the once-per-flight marker survives leg-1 landing. Closes #14.
skills/flight-assist/precheck.py — post-loop pass _check_connection_risks runs after every cycle's per-flight processing, reads the now-up-to-date flight states, calls detect_connection_risks, and flips connection_at_risk_fired on each fired leg-2 record before emitting the event. _initial_phase_markers includes the new marker key.
skills/flight-assist/SKILL.md — composition table gains a connection_at_risk row that renders the tight-connection notification. Description triggers extended with "connection at risk" / "tight connection alert" so the runtime matches the new intent.
skills/flight-assist/references/event-payloads.md — new "Cross-flight" section documenting the connection_at_risk shape and suppression rules.
skills/flight-assist/sync_tripit.py — _initial_state includes connection_at_risk_fired: false on the new-flight phase_markers dict.
skills/flight-assist/state.py — STATE_SCHEMA_VERSION bumped to 2. Owner-side migration (_migrate) handles the v1 → v2 upgrade: adds connection_at_risk_fired: false to per-flight phase_markers, rewrites at v2. Config and active-flights files have no shape change at v2; they receive a schema_version bump only via the same migration code path on first read. Per coding-policy: stateful-artifacts "Migration Policy", only the owner skill migrates — reader skills from other tiles continue to get StateError on mismatched version. Strict reader contract preserved: missing/wrong-type schema_version, schema_version higher than current, and corrupt JSON still raise StateError with actionable repair guidance.
skills/flight-assist/state.py — _PHASE_MARKER_KEYS extended with connection_at_risk_fired; the read+write validators reject phase_markers dicts missing the new key or carrying it as a non-bool. _CONFIG_OPTIONAL_FIELDS extended with min_transfer_minutes: int (with explicit bool-rejection on int fields to match the rest of the validator family).
skills/flight-assist/state-schema.md — documents v2 shape, the new connection_at_risk_fired marker (carried on leg-2 so it survives leg-1 landing), and the new optional min_transfer_minutes config field. The Migration Policy section names the v1 → v2 migration explicitly and reaffirms the owner-only migration discipline.
skills/flight-assist/sync_tripit.py — daily reconciliation of the active-flights index against byAir's list_trips. Reads upstream, diffs against the on-disk active-flights.json, writes initial state records for added flights, deletes state for removed flights, emits the same {wake_agent: bool, data: {events: [...]}} payload shape as precheck.py — sync adds use reason: "tracked_flight_added" and sync removes use reason: "tracked_flight_removed" so SKILL.md Step 3's composition table is the single consumer contract. Removed-flight events capture code + scheduled times BEFORE state deletion so the agent has the metadata it needs to render notifications. Same outer-boundary-process-contract carve-out as precheck.py. Exports initialize_flight_from_byair() for the precheck to call when it encounters a flight_id not yet on the index. stdlib-only.
skills/flight-assist/SKILL.md — full V1 action-router SKILL.md. Three actions: Diagnose env (preserved from v0.1.0), Set home base (records home_address to config via state.write_config), and Compose wake event notification (per-event-type composition table covering all 10 documented wake reasons — cancelled, diverted, gate_change, delay, inbound_delay_predicted, boarding_started, carousel_revealed, day_before, time_to_leave, arrival_logistics, removed_upstream). Multi-event merging rule (one notification per flight per cycle, ordered by urgency). References references/event-payloads.md for the full event-shape contract. Skill review: 90% (Description 90, Content 85).
skills/flight-assist/references/event-payloads.md — reference document for the precheck wake-event payload shapes. One section per reason with the JSON shape + when it fires + composition discipline.
skills/flight-assist/precheck.py — the scheduler-invoked entry point that orchestrates byair_client + maps_client + state + wake_rules + phase_markers. Reads active-flights.json, cadence-gates each flight (per state-schema.md's last_polled_at discipline), fetches new snapshots from byAir for due flights, runs delta detection (wake_rules) + time-based gates (phase_markers), persists updated state, and emits a single-line JSON payload on stdout: {"wake_agent": <bool>, "data": {"events": [...]}} per coding-policy: script-delegation "Precheck Gating". Uses the outer-boundary-process-contract carve-out: any unhandled exception is caught at the script boundary so the scheduler always sees safe-shape JSON + exit 0 (a bare programming bug would otherwise silently disable the wake contract). _run_cycle() takes now_utc as a parameter so tests pin the clock without monkey-patching datetime. Queries maps_client.travel_time() only for flights within the 6-hour time-to-leave window — preserves the Distance Matrix per-query budget.
skills/flight-assist/phase_markers.py — time-based wake-gate functions for the precheck. Three once-per-flight events driven by wall-clock time alone (vs wake_rules.py's delta detection): day_before (T-24h, capability 2's sanity check), time_to_leave (traffic-aware leave-by, capability 1), arrival_logistics (T-arr−15min, capability 6). Each function takes phase_markers (the per-flight state dict that tracks once-fired flags) plus a synthetic now_utc for deterministic testing. Returns (should_fire, event_dict). time_to_leave consumes travel_time_seconds from maps_client.travel_time().in_traffic_seconds; defers when None (the caller decides when to query maps per the cadence-ladder budget). Pure functions, no I/O, no state mutation.
skills/flight-assist/wake_rules.py — pure-function delta-event detector for the precheck. Takes (prev_snapshot, new_snapshot), returns a list of wake events [{"reason": "...", ...}, ...]. Event types: cancelled, diverted, gate_change (with side + from + to), delay (with delay_minutes + new_dep_time, threshold ≥15 min), inbound_delay_predicted (threshold ≥20 min, dedupe within 5 min vs prior magnitude), boarding_started, carousel_revealed (with baggage claim). First-cycle behavior: cancelled / diverted fire from a None prev (the state itself is news), other rules require a prior snapshot. RFC3339 timestamps compared in UTC so DST/offset shifts don't false-positive. No I/O, no logging, no state mutation — per coding-policy: script-delegation (deterministic logic stays in scripts).
skills/flight-assist/state.py + state-schema.md — per-flight state file read/write under /workspace/state/flight-assist/ (configurable via FLIGHT_ASSIST_STATE_DIR env var for tests). Atomic writes (write-to-tmp + os.replace) so a kill mid-write doesn't leave a half-written file. Three file types: config.json (home_address from /setup), active-flights.json (index of tracked flight_ids), flight-<flight_id>.json (per-flight record with snapshot, phase_markers, last_polled_at). All carry schema_version: 1. state-schema.md documents the full per-record contract per coding-policy: stateful-artifacts. Owner skill: flight-assist — when a future schema bump ships, the owner skill adds migration branches that upgrade-and-rewrite. Read-side validation is strict: schema_version must equal STATE_SCHEMA_VERSION and be a plain int (no bool, no string); flight_ids must be a list of plain ints (no silent coercion). Mismatches raise StateError with actionable repair messages per coding-policy: error-handling. stdlib-only (json + os + pathlib).
skills/flight-assist/maps_client.py — Google Maps Distance Matrix client for traffic-aware travel-time queries. Used by phase_markers.py (forthcoming) to compute the "leave by" deadline for the time-to-leave capability. stdlib-only (urllib.request + urllib.parse + json). Public API: MapsClient.from_env() + travel_time(origin, destination) → TravelTime (frozen dataclass with duration_seconds, in_traffic_seconds, traffic_factor, distance_meters, origin_resolved, destination_resolved). Uses departure_time=now + traffic_model=best_guess so every request includes a current-traffic estimate when the API returns one. MapsError(status, message) wraps non-OK top-level and per-element statuses (NOT_FOUND, ZERO_RESULTS, OVER_QUERY_LIMIT, REQUEST_DENIED, MALFORMED_RESPONSE); HTTP transport errors propagate as urllib.error.HTTPError.
skills/flight-assist/byair_client.py — Python HTTP client wrapping the byAir streamable-HTTP MCP endpoint as a JSON-RPC API. Used by the (forthcoming) precheck script, not registered as a Claude MCP tool inside the agent container — the precheck filters the ~13KB raw byAir response down to a ~1KB operational slice before any state write, so the agent never sees the full payload. stdlib-only (urllib.request + json) per coding-policy: dependency-management. Public API: ByAirClient.from_env() + get_flight() / list_trips() / get_flight_notifications(). Wraps isError: true responses as ByAirError(error_type, message); HTTP errors propagate as urllib.error.HTTPError. Sessions are managed lazily with one transparent re-init + retry on session-invalid 4xx; a second failure surfaces the underlying HTTPError so the caller sees the real transport error.
jbaruch/coding-policy: plugin-evals (2026-05-18). This tile is part of the jbaruch/nanoclaw-* plugin fleet — a fully-automated agent loop satisfying all three preconditions of the rule's "Narrow exception for closed-loop automated systems with no human eval-result consumption" clause: (1) no human reviews eval output for this tile in any form (no eval scores, no lift deltas, no scenario-by-scenario diffs, no regression alerts); (2) no automated gate consumes eval results (no evals.yml workflow, no publish-tile eval step, no downstream dashboard or paging route); (3) the owner accepts that re-introducing any consumption of eval results later — whether human review OR automated gating — requires re-introducing evals first under the standard requirement. Matches the carve-out previously claimed by jbaruch/nanoclaw-admin on 2026-05-09 and inherited by every jbaruch/nanoclaw-* tile thereafter. No evals/ directory ships in this tile.tile.json — declares jbaruch/nanoclaw-flight-assist 0.1.0, public, with one rule (flight-data-locality) and one skill (flight-assist)
rules/flight-data-locality.md — byAir is the single source of truth for flight data; second flight-data upstreams are forbidden by default. The motivation behind the rule: byAir pre-computes phase logic (computed_status, computed_phase_progress, computed_phase_risk, computed_phase_overdue) and inbound-aircraft prediction (inbound.predicted_delay). Mixing a raw-status API would force a translation layer between two semantically-different models. The byAir Pro subscription covers every operational field this tile needs, so a second upstream would add a separate budget, a separate key, and a separate rate-limit posture for marginal data. When byAir reports "boarding" and a second API reports "scheduled", the reconciliation question has no clean answer — one upstream, one truth. Eval of byAir's MCP on 2026-05-17 confirmed all six target capabilities are addressable from byAir alone (with maps/traffic as a separate axis).
skills/flight-assist/SKILL.md — minimal sequential-workflow skill with one step: run check-env.py, report missing credentials with actionable fix instructions. Will evolve into an action router as polling, state, and event composition land in subsequent PRs.
skills/flight-assist/scripts/check-env.py — env-presence check for BYAIR_MCP_URL and GOOGLE_MAPS_API_KEY. Emits single-line JSON; exit 0 always (info-only, not a gate).
.env.example — documents required environment variables per coding-policy: no-secrets, including the deep link to the GitHub Actions secrets configuration page so a new maintainer reaches the settings page in one click.
CI workflows — test.yml runs ruff + pytest on every PR; publish-tile.yml uses jbaruch/coding-policy/.github/actions/skill-review@<sha> (the canonical changed-skills loop) before tessl tile lint and tesslio/patch-version-publish on main.
pyproject.toml + requirements-dev.txt — pytest 8.3.4 + ruff 0.7.4, ruff scoped to tests/ and skills/ per coding-policy: code-formatting (every shipped Python file goes through lint + format check; new skill scripts under skills/<name>/scripts/ inherit coverage automatically).
MIT license — matches the public nanoclaw-* fleet.
.tileignore — excludes repo-only files (CI, tests, build artifacts, dev-time tessl-install scaffolding) from the published Tessl tile per coding-policy: context-artifacts.
skills
check-travel-bookings
flight-assist
nightly-travel-sync
sync-tripit