diff --git a/skills/wego/SKILL.md b/skills/wego/SKILL.md index f9806f1..8c7c9db 100644 --- a/skills/wego/SKILL.md +++ b/skills/wego/SKILL.md @@ -222,7 +222,7 @@ wego flights results \ [--currency USD] [--locale en] ``` -Translate the user's constraints into flags – every one of them, on this read. Re-supply still-active constraints on each refinement instead of assuming a previous filtered read changed the underlying search. Use `--stops 0` for nonstop. +Translate the user's constraints into flags – every one of them, on this read. Re-supply still-active constraints on each refinement instead of assuming a previous filtered read changed the underlying search. Use `--stops 0` for nonstop and `--stops 0,1` for at most one stop – the flag selects a set, never a ceiling (see below). - `--wait` performs a bounded server-friendly settle and adds a `settled: converged|budget_exhausted` field to the output; prefer it over hand-rolled re-read loops. - **The clock filters cover both ends of both legs.** Eight flags, one per `{outbound, return} × {departure, arrival} × {blocks, range}` combination. `--departure-*` and `--arrival-*` bound the **outbound** leg; prefix `--return-` for the return leg. Each reads local time at **that** airport, so `--arrival-range` is the arrival airport's clock, not the origin's. @@ -238,6 +238,7 @@ Translate the user's constraints into flags – every one of them, on this read. - `--outbound-min-duration` / `--outbound-max-duration` / `--return-min-duration` / `--return-max-duration` bound **one leg's** elapsed time in minutes, inclusive. These are always leg-prefixed because the unprefixed `--max-duration` bounds the **whole trip**, and the two answer different questions: a 3-hour outbound paired with a 14-hour return totals 17 hours, so no trip-wide ceiling can reject the long way home on its own. Use the per-leg flags for "I do not mind a long flight out but keep the return under 8 hours" (`--return-max-duration 480`). - `--booking-types` (`wego`, `airline`) accepts comma-separated enum values; like `--sort`, it is validated client-side before any network call, so a typo fails locally. - `--alliances` takes comma-separated codes in the server's own spelling, and is **not** a fixed set, so it is not validated locally: an unknown code returns an empty page rather than an error. Read `metadata.filterOptions.alliances` for the codes the current snapshot actually carries. Common ones are `star_alliance`, `oneworld` and `sky_team` (note the underscore), alongside groupings that are not strictly alliances, such as `lcc` for low-cost carriers. +- **`--stops` selects an exact set of stop counts, it does not bound them.** The values are OR'd, so `--stops 1` keeps trips with exactly one stop and **silently drops every nonstop** – on a route where the nonstops are the fastest itineraries, that discards the best options while still returning a large `totalCandidates` and no warning. There is no maximum form of this flag: express "at most N stops" by enumerating every count from 0, so "at most one stop" is `--stops 0,1` and "at most two" is `--stops 0,1,2`. Whenever the traveller says "at most", "no more than", "up to" or "direct if possible", that enumerated list is the flag, not the bare number they said. The count is **trip-level** – the maximum across the legs, the same value each card's `stops` field publishes – so a nonstop outbound with a one-stop return counts as one and is kept by `--stops 1`; do not fold `legs[]` yourself. Unlike `--alliances` and `--aircraft` there is **no `metadata.filterOptions.stops`** vocabulary to read, so enumerate from 0 rather than trying to discover the counts this snapshot carries. The counts are **unbounded and not bucketed** – 2 means exactly two, never "two or more", and a thin route can carry 3- and 4-stop trips as separate counts. So an enumerated ceiling can come back `totalCandidates: 0` because the route's *floor* is above it, not because the route has no flights: on a poorly connected pair the cheapest itinerary may be a 2-stop one, and `--stops 0,1` correctly matches nothing. Read that as "no trip is this direct", widen the enumeration, and say which ceiling you had to relax – never report it as "no flights found". - `--min-stopover-duration` / `--max-stopover-duration` bound the layover in **minutes**, inclusive, on a trip's **worst leg** – the largest leg total, never an individual connection. `--max-duration` bounds neither, because it adds flying and waiting into one number. The ceiling is exact about what it measures, since capping the worst leg caps every leg – but what it measures is **how long** a wait is, never **when** it falls. It is not an overnight filter: a 135-minute wait starting 04:00 is under any sane ceiling and is still a night in the terminal, and a 465-minute wait starting 11:00 is over it and never sees one. For "no overnight wait", bound the length with this flag if the traveller also wants it short, then read the connection's own clock – `flights trip --view detail` gives each segment's `arrivesAt` and `departsAt`, and the gap between them is the wait, in local time at the connection airport. **The floor is weaker than it sounds**: a leg waiting 450 then 510 minutes is judged on 960, so `--min-stopover-duration 120` does not promise every connection is 120 minutes long – on a multi-stop leg, or on the shorter leg of a round trip, a tight connection can survive it. When the traveller says "leave me at least two hours to change planes", send the floor **and then read the card**: `legs[].layoverMinutesByStop` lists one wait per connection, aligned to `via`, so you can see the real gaps and say so rather than implying a guarantee the filter does not make. A direct trip totals 0, so every maximum keeps it and any minimum above 0 drops it – pair a floor with `--stops 0` only when the traveller wants both. Read `metadata.filterOptions.stopoverDurations` (`{min, max}`) for the span the snapshot carries before picking a bound. - `--stopover-airports` (comma-separated IATA codes, e.g. `DOH`) restricts connections to those airports – use it for "connect through Doha" or "must have a stopover in X". - `--aircraft` takes comma-separated aircraft **codes** in the server's own spelling (`380`, `789`, `32N`), not the labels the cards print (`A380`, `B787-9`, `A320 Neo`). It is not a fixed set and is not validated locally: an unknown code returns an empty page rather than an error. A trip matches when **any** leg flies a listed code, so an A380 outbound with an A320 return still matches, and there is no whole-trip variant of this flag the way `--airlines-match all` is for airlines. Read `metadata.filterOptions.aircraft` for the codes this snapshot carries and the label beside each one, then filter on the code – several codes can share one label (`321` and `32S` are both `A321`), so the label alone cannot address them. Use it for a positive requirement - "I want the A380", "put me on a 787" - and never fold `results[].legs[].aircraft` yourself to answer that: the page is one slice of the ranked candidate set, so the cheapest few cards are not where a widebody has to appear. **It cannot express an exclusion.** "Anything but a regional jet" has no flag: passing the codes the traveller wants to AVOID selects exactly the trips they refused. Either enumerate the acceptable codes from `metadata.filterOptions.aircraft` and pass those, or say the API cannot filter that way and let the traveller choose from what the snapshot carries. diff --git a/src/commands.ts b/src/commands.ts index b065d51..4b29d50 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3964,7 +3964,7 @@ export const RESULTS_USAGE = usage({ ], ["--same-airline true", "One airline for the whole trip."], ["--alliances star_alliance", "Alliance codes, lowercase. Comma-list."], - ["--stops 0,1", "Max stops on the trip. Comma-list."], + ["--stops 0,1", "Exact stop counts. At most 1 is 0,1."], ["--min-price N", "Whole trip."], ["--max-price N", "Whole trip."], ["--max-duration N", "Whole trip, minutes."], diff --git a/src/help-shape.test.ts b/src/help-shape.test.ts index 3566eaa..672ec6e 100644 --- a/src/help-shape.test.ts +++ b/src/help-shape.test.ts @@ -163,4 +163,23 @@ describe("help shape", () => { expect(UNINSTALL_USAGE).toMatch(/--dir skill install is not touched/); expect(TELEMETRY_USAGE).toContain("WEGO_CLI_TELEMETRY"); }); + + // Issue #105. `?stops=` is a set filter: the values are OR'd against the + // trip-level stop count, so `--stops 1` selects one-stop trips and drops + // every nonstop. Calling it a "max" sent callers to the one spelling that + // silently discards the fastest itineraries on the route, and the failure is + // invisible downstream - the page is valid, totalCandidates is large, and + // nothing says a category was removed. Pin BOTH halves of the fix: that the + // bound word is gone, and that the enumerate-from-0 recipe is present. A + // description that only stopped lying would leave the flag correct and still + // unusable for the constraint callers actually state. + it("--stops reads as a set with the at-most recipe, never as a bound", () => { + const line = RESULTS_USAGE.split("\n").find((l) => + l.startsWith(" --stops "), + ); + expect(line).toBeDefined(); + expect(line).not.toMatch(/\b(Max|Maximum|Up to|No more than)\b/); + expect(line).toContain("Exact stop counts"); + expect(line).toContain("0,1"); + }); }); diff --git a/src/skill-embed.test.ts b/src/skill-embed.test.ts index 963fb8e..781f925 100644 --- a/src/skill-embed.test.ts +++ b/src/skill-embed.test.ts @@ -39,6 +39,18 @@ describe("readEmbeddedSkill", () => { expect(body).toContain("Operating contract"); expect(body.length).toBeGreaterThan(500); }); + + // Issue #105. The help text was the only surface describing `--stops`, and it + // described it backwards; the skill named just `--stops 0`, the single value + // where "exact set" and "maximum" agree, so it never contradicted the help. + // The skill is what an agent reads, so it has to carry the enumerated form - + // and unlike --alliances/--aircraft there is no metadata.filterOptions.stops + // to fall back on, which leaves these two surfaces as the only sources. + it("teaches the enumerated `at most N stops` form of --stops", async () => { + const body = await readEmbeddedSkill(); + expect(body).toContain("`--stops 0,1`"); + expect(body).toMatch(/`--stops` selects an exact set/); + }); }); describe("SKILLS registry", () => {