CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-flight-assist

Flight notifications via byAir: delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics. NanoClaw per-chat overlay tile.

72

Quality

90%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

CHANGELOG.md

Changelog

Unreleased

Fix — bound _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.

Fix — sync only the operator's own trips, not friends' (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).

Fix — 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.

Fix — don't flag same-day trips as missing hotel (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.

Fix — bound 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.

Fix — _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.

Added — 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:

  • Non-behavioral cleanup: ruff-driven formatting (single → double quotes); B007/F841 slug / item_count dead-variable cleanup in build-travel-db.py; explicit encoding="utf-8" on file opens
  • Hardening: build-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-write
  • Diagnostic accuracy: check-travel-bookings.py adds ensure_ascii=False on the error-JSON path so operator-facing diagnostic messages keep their non-ASCII punctuation intact
  • Stateful-artifacts contract — travel-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-corrupt
  • Test infra: tests/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 AttributeError

skills/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.pybuild-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.

Review fixup (#21) — non-owner snapshot reader API + boundary handler at main()

OpenAI policy reviewer requested changes on two precondition violations in PR #21 (commit 5103c8f). Both addressed here:

  1. 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.

  2. 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).

Feat — adaptive scheduler for sync_tripit (new 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.

Fix — byAir MCP client 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.

Fix — install wake task + origin-resolution ladder (jbaruch/nanoclaw-flight-assist#17, #18)

Two coupled bugs that left the skill installed-but-silent on a mobile traveller's flight days.

#17precheck.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.

#18time_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 — added

  • 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.

State schema — v1 → v2

  • skills/flight-assist/state.pySTATE_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 — added (V1)

  • 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.

Rules

  • Closed-loop carve-out claimed for 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.

Initial scaffold

  • 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 workflowstest.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.

CHANGELOG.md

README.md

tile.json