Install
openclaw skills install @dong845/travel-buddytravel-buddy: decide where to go first, then hand you an itinerary you can actually book (https://github.com/dong845/travel-buddy)
openclaw skills install @dong845/travel-buddyAct as a personal travel decision agent. Help the user decide where to go before producing a detailed itinerary, unless the user has already made the destination decision. Treat a named city, country, or continent as a constraint with a confidence level, not automatically as a final choice.
Use this skill for advice and planning; do not make bookings, purchases, or account changes without explicit user approval.
eu_eea_ch_citizen, member_state_residence_permit, eu_long_term_resident, short_stay_visa_or_visa_free, other_or_unspecified), never a document number, image, issue or expiry date, payment detail, or precise home address.python scripts/probe_sources.py --market <name> reports which evidence sources answer this
machine, per class — official, operator, dining, lodging, encyclopaedia, rates. Run it once when a
destination is settled, and again before writing any sentence of the form "this could not be
obtained".
A 200 is a candidate, not an answer, and a listing page is not a probe. Both halves are measured. In one delivered plan the author wrote that restaurant ratings and hours could not be obtained, having read an OpenRice listing page — the detail page carries per-weekday opening hours, the full address and the walk from the nearest station, which is the half that decides whether the traveller stands at a closed door. In the same plan the author wrote that accommodation prices and guest scores could not be obtained, having read Booking.com, which answers automated requests with a challenge page; hk.trip.com had already been probed as reachable in that session and was never asked for content, and its detail pages carry the guest score with its scale, the review count, the nightly rate with its currency, the station distance and the text of recent negative reviews.
Both sentences reached the traveller as a fact about the environment. Neither was one. The cost is not embarrassment — an agent that gives up early hands over an intermediate artifact where a booking-ready plan was possible, and the traveller cannot tell "nobody could" from "nobody opened the second URL". So a class of evidence is unavailable only after a DETAIL page for one real item, on a reachable source, came back without the field — and the plan says which field and which page. Anything short of that is written as "not obtained in this run", which is a different claim.
The probe reports reachability, never extractability, and it says so: most travel sites render
their content with JavaScript that no fetch here executes. It also distinguishes a host that
refused this client from a host that is down, because those need different next moves — and it
began by making that mistake about itself, reporting unreachable for a Chinese-language URL it
had failed to encode.
Use the skill for intake, constraint interpretation, candidate generation, hard filtering, scoring logic, explanations, and dependency-aware replanning. Use MCPs, APIs, or web research only to obtain current-world facts. If a required live-data capability is unavailable, leave the fact unverified and offer a range or verification step; never compensate by guessing. Retain a profile only for the active task unless the user explicitly asks to save it.
Read references/research-budget.md before launching any research fan-out. Five rules bind on every run:
feasibility.held_entry_documents and passport_validity_status carry the first, travel_window.date_flexibility the second, existing_bookings.state and .details the third, and each of those is a required field rather than an optional one, because an optional field on a disqualifier comes back null and the question gets asked in chat anyway — measured twice on one run, once for entry documents and once for bookings. new_plan_skeleton.py --from-intake prints an ALREADY BOOKED: line for exactly this reason: nothing copies bookings into the plan, so an author who never opens the intake file would ask a question the traveller has already answered. When the intake genuinely does not say, ask it once, inside the single consolidated checkpoint — never as a follow-up after design has started.None of this applies to the verification stage, which is not the place to save money — see that reference's "What not to economise on". It also separates tokens from minutes: every budget rule above buys tokens, and a fan-out's wall-clock is its slowest agent, so trimming agents saves tokens and no time at all. What the traveller actually waits on is the round-trip to them. Wrap each phase in python scripts/trip_timer.py start|stop <phase> --workspace "<ws>" --run <slug>, naming traveller-facing waits checkpoint..., so that claim stops being a guess — nothing in this skill has ever measured a minute.
For a first Travel Buddy use, or whenever no valid reusable profile exists, start the one-time reusable-profile HTML form after explaining its local storage and consent checkbox. Do not silently create a profile: the user must explicitly confirm local storage in that form. If the user declines, do not persist a profile and use a per-trip form only for that active request. Store a consented profile in the user-selected Travel Buddy workspace, which defaults to a Travel Buddy folder directly inside the user’s home folder and contains profiles, plans, and html; never put profile data in a shared cloud service by default.
digital_travel_access only as a convenience preference: map/booking apps, services to avoid, normal Google-service access, and non-sensitive booking-access notes; never store account context.python scripts/travel_workspace.py init; create a consented empty profile only after opt-in with python scripts/travel_workspace.py create-profile <profile-id> --consent; validate it before use.python scripts/start_intake_workflow.py --assistant auto as a background/non-blocking command, and provide the loopback link printed in the terminal. It starts the one-time profile HTML only when no valid profile exists; after save, it starts the current-trip service first and redirects the same browser tab to its prefilled HTML. The terminal prints the second local URL as a fallback. After a valid current-trip submission the service prints TRAVEL BUDDY TRIP INPUT: <path>, and you continue from that file yourself — under auto it deliberately launches nothing, anywhere, including from a bare terminal. That condition used to read "while an assistant is already driving the workspace"; it is now unconditional, for the reason set out below.serve_forever() until the traveller submits, which is minutes of real form-filling. Run it in the foreground and your own tool call is what holds the link hostage: the URL never reaches the traveller, and the harness's command timeout eventually kills the server mid-fill and takes the unsaved form with it. Use the harness's non-blocking form (Claude Code run_in_background, otherwise a backgrounded shell command with its output going to a file you poll), then read the link out and hand it over. Poll that output for TRAVEL BUDDY TRIP INPUT: rather than re-running the script — a second run opens a second server on a second port, and the traveller is already typing into the first.When the harness cannot do what the rules above assume. Backgrounding a command and fanning agents out are capabilities, and not every CLI this skill runs under has either. Degrade along this table — never by degrading the plan, and never by deciding the traveller declined something they were never offered:
| The harness has no… | Do this |
|---|---|
| background / non-blocking command | python scripts/start_intake_workflow.py --detach. It starts the server in its own session, waits until the port actually answers, prints the link and <workspace>/.intake-<port>.url, and exits 0 in a fraction of a second (three timed runs on the author's machine: 0.22 s, 0.21 s, 0.22 s). Everything the server says afterwards — TRAVEL BUDDY TRIP INPUT: <path>, and CURRENT-TRIP INTAKE URL: when the profile form hands over — streams into <workspace>/.intake-<port>.log: poll that file instead of a live pipe. It exits non-zero and quotes the server's own output when no server came up, so a link it gave you is a link that answered. A foreground run that blocked is not a declined form. |
| parallel agents | Run the five verification domains as five separate sequential passes, never one combined prompt. What splitting buys is concentration, not wall-clock, so sequential-but-separate keeps all of it. |
That is the whole point of auto, and it reads backwards until you see the incident: it used to spawn a non-interactive child whenever CLAUDECODE was set — i.e. it treated proof that an assistant was already handling the trip as the signal to start a second one. A measured run produced two plans in one workspace differing only by origin city in the filename, and the unattended one had the wrong origin, a superseded budget cap, no allergy data at all, and a traditional Brauhaus dinner for a traveller with a severe dairy allergy — saved as verification_status: verified, so its page carried no warning. The current rule is one sentence, and it has no condition in it: under auto the runner spawns nothing, anywhere, including from a bare terminal. It stands down, hands the intake path back to you, and prints one line naming the command that would have started a child on purpose. Force a detached run with --assistant codex or --assistant claude when you genuinely want one. Never resume the generic “last” CLI session because it may be an unrelated trip. If multiple profiles exist, ask the user to choose one by ID and rerun with --profile PROFILE_ID. Do not ask the user to download, move, upload, paste JSON, or type “continue”.
Superseded — the paragraph that follows describes what the first two fixes believed, not what the code does. It is kept verbatim, the way run_destination_discovery.py keeps its own superseded docstrings, because the belief is the thing that has to stay visible: both fixes were defeated by the same mistake, and a reader who sees only the answer will make it a third time. Read it as history, and take no instruction from it — in particular the --assistant none workaround it offers is for a spawn that can no longer happen.
autonow stands down by default and hands the intake path back to you; it launches a child only on positive evidence of a bare interactive terminal — stdin and stdout both a tty, and noCLAUDECODE/CLAUDE_CODE/CODEX_THREAD_IDset — which is the only case automatic continuation was ever for. The first fix tested this the wrong way round, by naming the assistants it knew and treating everything else as a bare terminal: under any other harness — Gemini CLI, Cursor, Copilot CLI, opencode, an SDK agent —autostill resolved tocodexand spawned the second planner, because a list of assistant names is only current on the day it is written and "did a human open this terminal" never goes stale. One residual gap, stated rather than hidden: a harness that allocates a full pty still looks like a terminal, so pass--assistant none(or exportTRAVEL_BUDDY_ASSISTANT=none) if you are in one.
That second fix was still half a guess, and the half it left is the half that bit. opencode, Cursor and Cline set none of CLAUDECODE/CLAUDE_CODE/CODEX_THREAD_ID and allocate a full pty, so "stdin and stdout are both a tty" read as a human at a keyboard on precisely the harnesses this skill is run on — and wherever codex sits on PATH, auto spawned the second planner exactly as before. The escape offered one sentence up, "pass --assistant none if you are in one", asks the harness to know it is the exception: the same losing bet as the name list, written as documentation instead of code. A tty proves a terminal device is attached and can never prove a human opened it. So auto now spawns nothing, anywhere, including from a bare terminal, and prints one line saying so and naming the command that would have. Spawning is opt-in only: --assistant codex, --assistant claude, or TRAVEL_BUDDY_ASSISTANT=codex. The cost is that a traveller alone in a real terminal types one more command; what it buys is the end of the Brauhaus dinner saved as verified.
python scripts/start_intake_workflow.py --edit-profile, which reopens the profile form preloaded with the saved values and then continues to the trip form. A stable default the traveller never saw is a default they never agreed to.intake_context — save_trip_deliverables.py refuses a plan that will not say how its requirements were collected.excluded_places marked never_recommend as hard filters. Treat visited places as diversity context, not a ban, unless the profile says not to revisit. Treat wish-list places as preferences, not commitments. Let the user’s latest request override every saved preference. The intake workflow carries these forward for you: never_recommend places prefill the trip form’s exclusion field, and saved dietary needs prefill its dietary field, so they land in the trip intake instead of staying in a profile file the shortlist may never open.python scripts/save_trip_deliverables.py <plan.json> --workspace "<workspace>" --verification "<report.json>", and report the exact Plan JSON: and Final HTML: paths. Those two lines are the only outward sign the gates ran at all — every check in this skill is a script, and a script runs only when it is called, so a hand-written page bypasses all of them and otherwise looks identical. The saved page now carries the gate stamp itself (data-gates-checks, rendered as a visible line in the source register), so a page without one did not come through this path; validate_trip_html.py prints a note saying so. Never hand the traveller a page you assembled yourself. That script validates the plan, its internal consistency, the verification report, and the rendered HTML before saving; without --verification it refuses to save unless you pass --unverified, which both records the gap in the saved plan and prints a visible "not fact-checked" banner at the top of the page itself — the traveller books from the page, so a gap recorded only in JSON is a gap they never see. When destination/date/entry/transport essentials are still undecided, label the result intermediate discovery and state the one blocker; never mislabel it as a final itinerary or invent a booking-ready HTML.entry, transport, sights_and_hours, booking_and_lodging, seasonality) plus two network-free auditors (consistency, completeness) — seven blocks on the full pass, which is what every plan in the author's workspace computes. The gate rejects a report missing any block the plan's own tier requires, and check_plan_consistency.py derives that tier from the plan rather than taking the report's word for it: a plan that qualifies for the light tier owes four (sights_and_hours, transport, and both auditors — see the work-mode section below). Submitting all seven is never rejected, so seven is the safe default and the tier only ever lowers the floor. The report carries the five in domains and both auditors in audits, and each block's claims_checked is a list of plan pointers that must resolve, not a count. Then resolve every wrong and misleading finding. The auditors cost the least and find the most: in the measured run they produced 27 of 55 findings and 5 of the 6 criticals, which is why they are required rather than encouraged. Run the five truth domains for Discovery only when a candidate's entry or seasonality could eliminate it. Verification splits into five because the domains share no state, and because one pass asked to check all of them at once gives each a fifth of its attention — which is how a dinner gets booked at a restaurant that closed three hours earlier. Fan them out when the runtime offers it (Workflow or parallel Agent calls in Claude Code, one codex exec child per domain in Codex); when it does not, run the five as separate sequential passes rather than one combined prompt. The benefit is concentration, not wall-clock, so sequential-but-separate keeps it. Hand each of the five its own projection rather than the plan: python scripts/plan_slice.py <plan.json> --domain <name> writes slices/<plan-stem>.slice-<domain>.json beside the plan and prints exactly which top-level blocks it removed. It slices by top-level key and nothing else — kept blocks are the same objects in the same order — so a claims_checked pointer into a kept block still resolves against the real plan, which is what this gate will check it against. What it does not slice: the two auditors and the gate itself. consistency and completeness compare two parts of the plan to each other, so a block removed from the file is indistinguishable from a block the plan never had, and the script refuses both names with that reason rather than a usage error; check_plan_consistency.py reads the whole plan too. The saving is uneven and the tool reports it signed — on a small plan a slice can come out larger than the plan, and when it says so, hand that domain the plan path instead.Resolve every planning choice in this order: the user’s explicit current-trip answer, then a compatible stable profile default only when that current field is empty or unspecified, then researched destination/route feasibility. Never infer how far the traveller wants to go from nationality, residence, language, or currency; trip_geography.scope is the current-trip authority. Nationality and residence status answer a different question — what entry a given destination requires — and never set the scope. Use selected current-trip transport modes to select the research and booking branches. Apply saved self-drive, seat, map, and booking preferences only when compatible with the actual destination market and the current-trip selection. If an explicit trip scope conflicts with a named destination, surface the conflict and ask the smallest clarification instead of silently changing either value.
Choose the mode before researching:
| User state | Mode | Required result |
|---|---|---|
| No destination, or only a broad continent | Discovery | Generate, filter, and rank destinations. |
| Country/region named but city not chosen | Constrained discovery | Compare suitable subregions/cities before planning. |
| Destination deliberately selected | Construction | Build a feasible trip plan and budget. |
| Existing plan plus a new constraint | Incremental replanning | Change only affected elements and show the impact. |
Do not collapse Discovery into Construction. A user who says “I have seven days and €1,500” needs destination options and trade-offs, not an invented day-by-day itinerary.
Construction has two tiers, and picking the wrong one is expensive in one direction and dangerous in the other. Decide once, at this table, and say which tier you chose.
Light — four blocks: sights_and_hours, transport, consistency, completeness. Allowed only when every one of these holds: the traveller stays inside their country of residence or the plan's entry_context.status is not_required; no flights, ferries, rental cars or ticketed rail/coach/ferry legs; trip.traveler_constraints.allergy_severity is none or preference and max_continuous_walking_minutes is null and neither is still marked untyped — new_plan_skeleton.py cannot know either field, so it leaves an entry per unanswered one in trip.traveler_constraints.untyped_constraints, and while that marker is present those two values are the skeleton's defaults rather than the traveller's answers, which is precisely the state that used to buy this tier; and the trip is three nights or fewer in one city. Roughly 300k rather than 700k. check_plan_consistency.py computes this from the plan's own fields, so do not argue with it — read what it printed. It prints it on every run, as a verification tier: note carrying the tier, the blocks it implies and the reason, and that is new: the tier used to be computed only inside the report check, which runs when a finished verification report is handed in — i.e. after the pass had already been bought, which is the one moment the answer cannot help. Read that line before you commission anything.
The three that drop out drop out for a reason, not to save money: entry re-litigates a fact rule 5 of the research budget forbids re-litigating; booking_and_lodging verifies whether dates are sellable and whether search URLs prefill, and a light-tier trip by definition carries no bookable transport product for that to bite on — the moment a ground_transport card exists it asserts a fare, an availability status and a search URL, which is exactly that domain's subject, and the tier is lost; seasonality matters where the plan schedules a sunset or depends on a seasonal service, and a two-night city break with indoor anchors does neither. The four that stay are the ones that caught real defects: opening hours are weekday-keyed and wrong hours close a door in the traveller's face, a city break is mostly local transport, and the two auditors need no network at all while producing, in the measured run, 27 of 55 findings and 5 of the 6 criticals.
Full — all seven blocks. Everything else, and specifically: any flight, ferry or rental car; any entry question that is not already settled; a severe allergy or a stated walking cap, because those are the constraints whose violation is a medical or a stranding risk rather than a disappointment; four nights or more; more than one city.
When in doubt, full. The light tier is a floor for the genuinely simple trip, not a default — and a plan that qualifies for it today stops qualifying the moment a flight or an allergy enters, so re-check the tier after any replan.
The loopback HTML form is the intake path. Chat questioning is not an alternative you may choose — it is a fallback the traveller chooses, by declining the form. For a first-time traveller, or any traveller without a valid reusable profile, your first action is to start python scripts/start_intake_workflow.py --assistant auto in the background (see the background rule above — run in the foreground it blocks until submission and the link never reaches the traveller), give the traveller the printed link, and wait for its sequence: one-time profile form when needed, then current-trip form, then TRAVEL BUDDY TRIP INPUT: <path>. Because you are an assistant already driving this workspace, auto will not launch a discovery task for you — that path is yours to continue, from that file, in this session. Do not repeat the questions as a long prose questionnaire or instruct the user to type “continue”.
None of these is a reason to switch to chat, and each has been used as one: the form feels slower; you already have most of the answers; the traveller seems in a hurry; you would rather not manage a background process; chat feels more natural in this harness. Offering chat instead of the form, or asking "form or just tell me here?" as if the two were equivalent, is itself the defect — the traveller cannot weigh what the form does that chat does not. Skipping it loses the intake server's outright rejection of document, payment, password and exact-address fields; its refusal of a destination scope that contradicts the work mode; the profile's never_recommend exclusions and dietary needs prefilling into this trip's answers; and the saved intake file that check_shortlist_consistency.py --intake computes the hard-constraint roster from — without it that gate refuses to run at all unless you pass --no-intake, which stamps a NO INTAKE banner saying the shortlist was never tested against the traveller's stated constraints. If you genuinely cannot run a background command in this harness, say exactly that to the traveller and let them pick; do not decide it for them by never mentioning the form.
Before you say that, run it with --detach (see the harness table above). "This harness has no background primitive" is what that flag is for, and it keeps the form available on opencode, Cursor, Cline and Codex too — so it is almost never true that the form cannot be offered. It also closes the trap that follows: a run that blocked and timed out leaves you with no intake file, and chat_fallback requires declined_verbatim — the traveller's own words declining the form — which a traveller who never saw a link has not said. Neither html_form nor chat_fallback is then honestly available, and the only remaining moves are to invent a quote or to abandon the save. Get the link out with --detach instead.
Chat intake is legitimate on exactly two conditions: the traveller declined the form (or declined profile storage), or they already supplied the information another way. Both are recorded, not remembered — the plan carries intake_context naming the method, and for chat_fallback the traveller's own words declining it plus the date. save_trip_deliverables.py refuses to write any plan whose intake_context is missing, invented, or unevidenced, and there is no bypass flag, because the three methods already cover every legitimate route. Retain chat-collected facts only for the active task.
When a consented profile is available, first summarize only the relevant saved constraints and ask whether anything changed. Do not re-ask confirmed stable fields. The HTML trip form must collect or confirm these seven fields before claiming a destination is a strong fit. First ask one yes/no: does this trip need a visa the traveller does not already hold? Do not ask "domestic or cross-border": geography is a bad proxy for entry burden, and it misfires for exactly the travellers it matters most to — someone holding a member-state residence permit crosses into the Schengen area with no visa at all. Do not ask about visa effort either. Effort only matters to someone who still has to apply, and asking it first spends the expensive research on a traveller whose own passport had already settled the question.
The two answers are different constraints, not two points on one scale:
feasibility.held_entry_documents (what they enter on — a held multi-entry visa, a residence permit, visa-free, or simply being at home), and passport_validity_status, which is still required here. A held visa does not make an expired passport board a plane, and this answer also covers travellers who are leaving the country, so the domestic opt-out (not_applicable_domestic) must be chosen rather than assumed. Set entry_status: traveler_asserts_can_enter. The document string also constrains destinations: candidates are limited to what it actually admits them to, so this answer is a hard filter on the shortlist, not a free pass.The reason this ordering matters is measurable. A real run researched a complete Japan visa procedure for a PRC passport — consular jurisdiction, designated-agency rules, fee schedule, processing times, roughly 100KB of output — and the traveller's next message was "我有签证". One yes/no, asked first, deletes that entire branch. Entry burden follows from destination country × traveller status; status lives in the profile (identity_and_language.residence_status), not in a per-trip question. Always collect maximum one-way journey time, applicable climate constraints, and transport modes—including high-speed rail, conventional/night rail, intercity bus, ferry, flights, and self-drive where relevant. Accept rough answers and mark them as approximate.
start_date/end_date when the traveller has them, otherwise month/season plus duration; date flexibility; and any fixed commitment the trip must fit around (a return-to-work date, a wedding, an event). Exact dates are authoritative: a Construction task cannot start without them, so never downgrade a supplied date pair to a month.feasibility.dietary_or_religious_needs empty by assumption.fixed or anchored scope must name at least one actual place; a fixed scope with no named destination is blocked, not a Construction task.After the form returns or the user answers, read the saved intake and summarize it in a compact “What I heard” block. Label unknowns and assumptions. Treat the saved budget range as per-person; show any party total only as an explicit calculation. Do not silently turn “October” into fixed dates or “Europe” into a visa-safe region.
Ask these only when they can change the short list or feasibility. Prioritize the unknown with the greatest expected impact.
valid_through_trip, not_sure, or needs_renewal); visa tolerance, which is derived from the scope answer rather than asked again. Ask for the status category, never a document number, image, or expiry date. Do not request any of it, or treat it as a blocker, when the traveller is staying inside their country of residence.When several details are missing, ask at most three high-impact follow-ups in a turn. If the user prefers not to answer, proceed with conservative assumptions and say how they may alter the ranking.
Record each value as one of hard, strong_preference, nice_to_have, avoid, unknown, or assumption. Preserve the user’s original wording alongside the normalized value. Start from templates/trip-profile.json for the active-trip structure; merge consented stable values from the reusable profile only after stating the applied fields.
Read references/initial-intake.md before you send the traveller the form link, and again when its file comes back — it holds the scenery/activity taxonomy those seven answers are recorded in, the per-person budget model, the destination-scope rules, and the two feasibility answers that must leave the interview as machine-readable values rather than prose. That trigger used to read "before designing a multi-question intake", and this skill forbids you to design one: the loopback form is the intake. The condition was therefore false on the only path you are allowed to take, so a 14,881-byte reference switched itself off — which is what a trigger costs when its antecedent describes work the skill has since prohibited. Read references/regional-service-routing.md before selecting map, transport, or booking providers.
For Discovery and Constrained discovery:
If no candidate survives the hard filters, stop before scoring. State that no recommendation is currently feasible, name the smallest set of conflicting constraints, and ask the user which one may relax. Offer conditional examples only as “possible if X changes”; never disguise an infeasible destination as the winner.
Write the shortlist as JSON — templates/discovery-shortlist.json is the envelope and templates/destination-evaluation.json is one candidate inside it — and run python scripts/check_shortlist_consistency.py <shortlist.json> --intake <workspace>/plans/intake-<stamp>-<slug>.json before presenting it. --intake is not optional: with neither it nor --no-intake the gate refuses to run, because omitting it used to print an accurate note and exit 0, and an exit 0 is what an assistant reads. It computes the hard-constraint roster from what the traveller actually declared and requires every candidate to answer each one — the check that catches a winner that cleared the four constraints someone remembered and was never tested against the fifth. --no-intake runs without it when no intake file exists and prints a NO INTAKE banner; when you use it, say so as you present the shortlist and never describe a winner as having cleared the traveller's requirements. A roster written by hand into the shortlist cannot do this — it reports full coverage on exactly the run that motivated it. Declare outcome.state (shortlist / constraint_conflict / blocked) on every Discovery artifact: an unfinished filter and a real conflict produce the same empty pass set, and only one of them justifies asking the traveller to give up a requirement. A shortlist is a comparison, and its worst defects live between candidates rather than inside one: each record can be impeccable while the ranking is meaningless — one figure priced per person beside one priced for the whole party, or one covering flights beside one covering everything. The traveller picks the smaller number and it was never the smaller trip, and nothing inside either record is wrong. So every priced candidate declares cost_estimate.cost_basis: per_person beside its own figure rather than relying on one declaration at the top, states the same currency and party size as the shared trip_context, and accounts for every category in trip_context.budget_scope — priced, or declared in not_applicable_categories / unverified_categories with a reason, because a silently absent category is indistinguishable from one nobody priced. A candidate shown without a figure gives not_priced_reason. Arrival modes (flight, rail, ferry, bus, car) count as one cost surface, so a rail-reached candidate compares against a flown one with no declaration from either. The gate also refuses a winner that fails a hard constraint or whose filter never ran, and a winner named when no candidate was feasible — an empty feasible set is an outcome, not a scoring error. Then save it: python scripts/save_discovery_deliverables.py <shortlist.json> --workspace "<workspace>" --intake <intake.json> (or --no-intake), and report the Shortlist JSON: path it prints. That line is the only outward sign this gate ran at all, exactly as Plan JSON: and Final HTML: are for a Construction task — and until it existed nothing made a Discovery run observable: a real workspace holding fifteen saved intakes contained zero shortlist files, so this thousand-line gate had never run on a single real Discovery. There is no HTML here on purpose. A traveller books from a plan, standing in a city with a phone; a shortlist is a decision aid they read once, in this conversation, and the comparison you write them is still the answer. The saved JSON is the record that the comparison was tested.
Read references/decision-and-research.md before the first research call of either mode, not only before scoring: besides the scoring and evidence rules it carries the record-once habit that decides whether a page gets opened once or twice, and a Construction task that skips it meets every required field at the end, when the pages are closed. Use it for scoring, research, evidence, and output rules. Start each candidate record from templates/destination-evaluation.json.
Lead with a recommendation, then a compact comparison containing:
Explicitly answer “why this, not that?” and “what was filtered out, and why?” when those questions matter. Do not hide visa, weather, transport, or budget compromises behind a single score.
Once a destination is selected or intentionally fixed, research and assemble transport, lodging areas, local mobility, activities, daily pacing, meals, reservations, and a line-item budget. Ensure that every daily route is geographically and physically feasible. A city stay of three or more days must include at least three destination-specific experience anchors across at least two days when they fit the traveller’s stated interests; do not substitute a list of famous sights for real fit.
Set a regional service context before producing a live link: trip market, normal Google-service access, primary map provider, checked alternatives, local transport authorities, booking-platform rationale, and a non-sensitive access check for every booking category shown. A mainland-China route uses a verified Amap primary link unless the user asks for and can access another verified local provider; do not render Google Maps as its default. For multi-country trips, select by route/segment and show the provider change.
Separate:
Use live sources for bookable or time-sensitive claims. If live data is unavailable, give a planning range and a verification checklist, not a false confirmation.
After the destination and trip inputs are decision-ready, create one self-contained, responsive HTML page rather than an unstructured prose itinerary. A fresh skeleton fails its own gate on purpose, and the errors are a worklist, not a defect
list: one line per card nobody has filled in yet, plus the walking figures that have to be
derived. A four-day skeleton reports twenty — measured at eighteen before the skeleton started marking the two traveller constraints it cannot know, so two of the twenty are that marker. (Sixteen is the three-day figure; the count is 12 / 16 / 20 / 24 for a two- / three- / four- / five-day skeleton, four per added day. An earlier revision of this line quoted the three-day number as the four-day one and a "before" figure nobody had taken — if you restate a count here, re-derive it from the script rather than from this sentence.) They used to be thirty-eight, because the
content rules ran on placeholder values and reported things like "rated 0/5, below the 3.5/5
floor. Replace it" about a restaurant nobody had chosen — an error that sends the author hunting
for a problem that does not exist is worse than no error, because it costs a round trip and
teaches distrust of the gate. Content rules now stay quiet on a card that still carries a TODO:
marker, and validate_trip_html.py refuses to ship one regardless, so nothing is lost.
When the plan follows an intake file, start it with python scripts/new_plan_skeleton.py --from-intake <workspace>/plans/intake-<stamp>-<slug>.json > plan.json rather than retyping the flags. It carries across origin, dates, party, currency, budget.cap_per_person, and the two list fields of trip.traveler_constraints (dietary needs, mobility notes), printing every field it copied and every one it could not derive. It does not set allergy_severity or max_continuous_walking_minutes — the intake collects those as prose and nothing converts a sentence into a severity enum or a number, so the skeleton leaves both null, names each one in trip.traveler_constraints.untyped_constraints with a TODO: saying what to write, and says so on stderr. check_untyped_constraints refuses the plan while an entry remains and the verification tier reads the marker as full; delete an entry only when you have typed that field. It emits neither field as a value that reads like an answer — allergy_severity: "none" and a null cap were exactly the pair that switched every walking check off and bought the light tier, measured: on a real five-day plan with those defaults, deleting every activity's on_foot_minutes changed the finding count from 2 to 2, while the same deletion against a typed cap of 25 produced five precise findings. Type them yourself before delivering: the verification tier reads exactly those two fields, and a plan that leaves them at their defaults while its own prose says 「anaphylactic」 is a plan claiming there is nothing to avoid. That matters more than the typing it saves: built from flags alone, cap_per_person stays null and the budget cap-overrun check has nothing to compare against, and max_continuous_walking_minutes stays null so every per-leg and per-activity walking check silently no-ops — the traveller's three most dangerous constraints collected by the form and then measured by nothing. Without an intake file, the flag form still works: python scripts/new_plan_skeleton.py --start <date> --end <date> --origin <x> --destination <y> --language <zh|en> --currency <c> --travellers <n> --mode <public-transit|self-drive> --stops-per-day <k> > plan.json. Then fill it. The skeleton already obeys the structural rules a template cannot express — segments mirroring stops_in_order by exact string equality, a non-empty service_or_line on walking legs, the booking-access enums, a breakfast card on the departure day, distinct flight review_urls — so the render loop is spent on facts instead of rediscovering the contract; one measured run lost three edit-render round-trips and 21 structural errors to that. Every unfilled value is a TODO: marker that validate_trip_html.py refuses to ship, so a faster start cannot become a hollow page. Open templates/final-trip-plan.json for the part the skeleton leaves to you: it emits booking_options.attraction_tickets and booking_options.rental_cars as empty arrays and omits booking_options.ground_transport entirely, so for those three the template is the only written record of what one item contains — and its _contract block is where the optional-but-load-bearing pieces (trip.traveler_constraints, entry_context, on_foot_minutes) are explained, none of which any gate asks for by name. Structure is not yours to start from: render it, every time, with python scripts/render_final_trip_html.py <plan.json> <final.html>. For every completed Construction task, persistent delivery is mandatory: run python scripts/save_trip_deliverables.py <plan.json> --workspace "<workspace>" --verification "<report.json>" (or --unverified for a deliberately labelled draft — the script refuses outright without one of the two) and report both output paths. Do not end a completed trip-planning task with prose alone.
The page must include a trip summary, comparable budget, every day’s timed route, accommodation, activities, map representation, source register, and only verified outbound links. For each required purchase, show browse-and-choose options rather than purchasing:
When trip.language is Chinese, render every renderer-owned heading, action label, status, unit, and fallback in Chinese; when it is English, render those elements in English. Retain another language only for a proper name, platform name, or deliberately user-supplied/source text. validate_trip_html.py enforces this: on any page whose <html lang> is not English it fails on renderer-owned English, including enum values printed as visible text. That is why plan_status, ticket_status, budget.breakdown[].category, budget.included_categories, budget.unverified_categories, dining[].meal, trip.arrival_transport_mode, transport_preference.mode, trip.traveler_constraints.allergy_severity, and sources[].confidence are closed enums rather than free text — an arbitrary category string cannot be translated, so it would leak. sources[].confidence is the newest of them and the one that proves the rule: it was free text, and eleven non-English pages in the author's workspace printed high and medium in an otherwise fully Chinese source register — nine of them dated deliveries, one of those the workspace's only gate-stamped plan — with both gates calling them VALID. Use high, medium, low, researched, unverified, and put any qualification (fare requires recheck) in claim_or_decision_supported beside it, not in the enum. Use budget categories from flight, rail, intercity_bus, ferry, rental_car, fuel_tolls_parking, accommodation, food, local_transport, attractions, tours_and_activities, insurance, visa_and_entry, shopping_and_misc, contingency, and booking states from idea, researched, held, booked.
When entry feasibility was assessed, put the conclusion on the page through the optional entry_context block (status, summary, traveler_basis, source_url, checked_at). State the basis as a status category, never as a document detail: a traveller rereading the page a month later needs to know why no visa was required, not merely that none was.
The page must also render what the plan already collects, because a required field that is never displayed is research the traveller paid for and cannot see: each day’s route.fallback_plan and route.walking_burden; each flight’s material_conditions; any single_option_reason explaining why only one option is shown; budget.unverified_categories; plan.assumptions; and regional_service_context.booking_platform_selection_note. Group booking options by type rather than mixing flight and hotel cards in one grid, and give the page a sticky in-page navigation with one link per day.
booking_options.ground_transport, held to exactly the flight standard because the traveller asks the same questions: which service, when it leaves and arrives, how long, how many changes, what the fare lets them do, whether it is still sellable, and how they get from the arrival station into town (station_transfer_note, the ground analogue of an airport transfer, so a cheap fare cannot hide an impractical arrival). Render a verified round-trip search button whose prefill fields carry origin, destination, both dates and travellers. This category exists because a rail trip's largest and most time-sensitive purchase previously had no card at all: the page compared three hotels and offered no way to reach, price or availability-check the train. validate_plan derives the requirement for you when trip.arrival_transport_mode is rail, or is road between two different places on public transit; a mid-trip city hop on a fly-in trip is invisible to that test, so pass --require-booking-type ground yourself in that case. Carrying the card also costs the plan the light verification tier, on purpose — see the tier table above./hotel/es/las-palmas.html), and the bare /hotel/<cc>/<slug>.html returns an error page unless it carries the session parameters this skill forbids embedding — a property-scoped search is the form that is stable, shareable and tracker-free. Check availability and the total-for-the-stay on that page before the card claims either;For every shown booking category, expose its access status (available, limited, or unknown) and non-sensitive requirement/caveat in the page's booking-access check. Treat a visible search result as a shopping lead, not evidence that the traveller can complete the booking. For rail, intercity bus, ferry, transit, attractions, and rental cars, verify the official/operator conditions that can alter ticket eligibility, payment/deposit, required local phone, or licence feasibility; never ask the user to supply credentials, payment details, or document images.
trip.traveler_constraints and trip.traveler_preferences come off the same intake form, and for
a long time only the first survived into the plan: the skill remembered the allergy and forgot the
reason for the trip. Everything downstream inherited that. The renderer counts experience anchors —
three minimum on a multi-day city trip — but nothing ever asked whether an anchor answered
anything, so rewriting every one of them as "somewhere / no particular reason" produced zero
findings from any gate. "Do not substitute a list of famous sights for real fit" had a headcount
behind it and nothing else.
So carry ranked_must_haves, the natural and cultural subtypes, the pace and the avoid list into
the plan — new_plan_skeleton.py --from-intake now does it — and point an anchor at each
must-have through satisfies_preference, quoting the traveller's own words. Only the ranked
must-haves bind; the softer preferences produce a note, because "prefer mild warmth" is a quality
of a choice already made rather than a thing the days must contain, and a rule that failed on it
would fire every winter. When the season or the place genuinely cannot deliver a must-have, say so
in unmet_preferences with the reason — that is a different act from ignoring it, and the page
shows the traveller which of their own words each anchor is answering, because they are the only
reader who can tell whether "old-town lanes" is what they meant.
Every entry in avoid_list needs an avoid_list_handling entry saying what keeps it out. Asked
rather than pattern-matched: deciding from a plan's own fields whether it contains a red-eye, a
crowd or a long transfer needs a different fact for every avoidance a traveller might write, while
asking how each was honoured needs none.
All of it renders, and that had to be fixed rather than assumed. Measured with canary strings run
through the whole save path: avoid_list, every avoid_list_handling.how_avoided, and the natural
and cultural subtypes were present in the plan JSON and reached the HTML not once — the gate
demanded an answer and the page never showed the traveller it had been given. They now render in a
What you asked for panel beside the constraints one, which is the same principle this skill
already applies to ratings: stored and never shown is the same defect as never gathered. When you
add a field the traveller stated, check the rendered page for it rather than the JSON.
The prose in the delivered plans is already specific and reason-led — no "vibrant tapestry", no "nestled in the heart of", every rationale tied to a real opening time or the traveller's own walking limit. It still reads generated, and measuring it showed why. The tell is not vocabulary, it is sameness.
check_plan_consistency.py refuses a plan where more than 35% of narrative fields lean on it.focus and route_logic came back
byte-identical on 4 of 5 days of one shipped plan and 5 of 8 of another, and fallback_plan
duplicated contingency on nearly every day. Each field alone looked filled in. Now refused
within a day; across days it is a note, because two days can honestly share a wet-weather
fallback.So: vary the shape. Some rationales are one blunt sentence. Some are two. Some name the constraint first and the consequence second, some the reverse, and some just state a fact and stop — not every line owes the reader a moral. Cut the closing flourish that explains why what you just said matters; if it needed explaining, the sentence was wrong. Where two fields want the same sentence, one of them has nothing to say, so leave it out rather than pad it.
What not to do: swap the banned words for synonyms. A word list is satisfied by "bustling" → "lively" while the writing stays exactly as hollow, and it fires on the traveller's own phrasing. Structure is what these checks can see honestly, and structure is what actually reads wrong.
The page is read on a phone in a city the traveller does not know, and it was 96KB of unbroken text. Two things fix that, and they are not the same kind of thing.
Figures are free and always on. plan_visuals.py draws each day's stops at their true
relative positions, minutes on foot per day, budget composition against the cap, and where the
day's fixed points fall on a clock — all from numbers the plan already carries, so they need no
network, no licence and about 15KB. They also answer the questions text cannot: a list of stops
gives you the order, only a map gives you the shape.
Photographs are earned, and optional. Run
python scripts/fetch_plan_imagery.py <plan.json> during the verification stage, not after
it — it is network-bound and independent of every verification block, so it costs no extra
wall-clock when it runs alongside them, and about 13 seconds when it does not. The photographs
do not live in the plan. They are written beside it as <plan-stem>-imagery.json and the plan
keeps only an imagery_sidecar key naming that file, so pass the plan by path from here on —
a relative sidecar name has nothing to be relative to when the plan arrives on stdin, and
save_trip_deliverables.py refuses that combination rather than delivering a photo-less page.
It carries the sidecar into the workspace for you and prints a third line, Imagery sidecar:,
whenever the plan carries photos; report it alongside the other two. It fills only the
destination hero and the experience anchors; restaurants are left alone because Commons coverage
of an individual restaurant is near zero and the only way to fill those slots would be a generic
photo of food, which is decoration pretending to be information.
A photograph that cannot be verified is not added. Coordinate proximity proves "near the place", never "of the place": a real search for "Alicante Central Market" matched the article Bombing of Alicante, 400 m away, whose lead image genuinely is the market — and that provenance would have been printed under the photo. So the article's title must also be about what was asked for, a search that falls through to the destination's own article is refused, and no file appears twice. When a slot cannot be filled to that standard it stays empty. Never substitute a stock image, and never hot-link one: the page must keep working on a phone with no signal, which is exactly when it is needed.
An anchor's name is a lookup key too, and the same rule decides it. fetch_plan_imagery.py
searches Wikipedia by the anchor's name, so a name written for a human to read finds nothing:
长洲(渡轮往返,海滨平路) is a caption, 長洲 is a key. Write the name the way the destination's
own reference source titles it, and put the description in why_it_matters where it belongs. This
is the same sentence as the map rule one section down — the string a lookup carries is a query, not
a caption — and it took a delivered page with zero photographs to notice the rule had never been
applied to this field.
Two of the three causes of that page were in the script rather than the author, and both are fixed;
they are recorded because they say what to distrust next. A token floor of three characters, tuned
for Latin where it only rules out the and and, erased every two-character CJK name:
_tokens("香港") and _tokens("长洲") both returned the empty set, so the hero lookup failed on
香港, 北京, 上海, 东京, 台北 and every other two-character destination, on every Chinese-language
plan, silently. And the relevance rule ran on exact title lookups, where zh.wikipedia had already
resolved 中环街市 to 中環街市 — the two share no token across writing systems, so the correct
article with the correct image was thrown away by the check meant to protect it. A Latin constant
applied to a script nobody measured it on, twice.
A run that fills nothing now says so on stderr (NO IMAGERY: N slot(s) were offered and none could be filled) and names the three causes in the order worth checking. It used to exit 0 with a
few note: lines that had already scrolled past, which is how a page shipped with no photographs
and nobody looked. A page with no photographs is a legitimate outcome — some articles genuinely
carry no lead image, and that is a gap in the source, not something to work around. Shipping one
without knowing which of the three it was is not.
This is the highest-severity defect the skill has shipped, it is invisible to every structural
gate, and it stays inline for that reason. A delivered plan wrote its own display labels into its
Google Maps URLs — origin=酒店(拉斯坎特拉斯海滨), literally the word "hotel" plus a description,
and destination=圣安娜广场:主教座堂钟楼与哥伦布之家, which carries no place name in any script a
Spanish geocoder reads. Google resolved the first to Taiwan and offered a 65-hour drive to the
Canary Islands; the second returned "destination not found". Six of that plan's fifteen distinct
endpoints could not geocode, four contained no Latin-script token at all — and
check_link_targets.py reported all 25 map links ok, because the host was right, the status was
200 and no parameter had been dropped. Nothing measured whether an endpoint named a place.
So: the string a button shows and the string its URL carries are two different fields, and one may never be copied into the other. Write the endpoint the way you would type it into that provider's own search box.
origin=28.1025,-15.4135 cannot mis-geocode, is language-independent,
and returns real transit and walking directions. Record lat,lon for every stop as you research
it — the place page that gave you the venue's rating and opening hours put the pair in its own
URL. Free text is refused outright, and the reason is that the first version of this rule only
warned about it: a plan rebuilt with the original captions and one line changed passed clean.
A name sometimes resolves (Mercado de Vegueta) and sometimes lands on another continent
(酒店(拉斯坎特拉斯海滨)), and no offline check can tell those apart — only a geocoder can, and
it is not in the gate.lat,lon; Amap reads lon,lat,name. Guessing by range was tried and broke
the one market this skill mandates a non-Google provider for: Ürümqi written correctly as
87.6168,43.8256 was read as latitude 87.6 and reported 4,946 km away, and an author who
followed the error message and "fixed the order" got a green gate with every button pointing at
the Arctic. Kashgar and Shigatse were out by 4,439 km and 6,691 km the same way.mainland_china plan, but only for three route fields and only on an exact market string, which
left the trip's own top-level map button, every venue_url, every booking URL, and any market
spelled 中国大陆 outside it. check_link_targets.py cannot close that either: it asks whether
a host answers the machine running the check, and that machine is never inside the blocked
market. So
regional_service_context is now binding rather than decorative: fill
destination_service_market, and set google_services_access to available or unavailable
after actually establishing which — unknown is refused once the plan ships Google links,
because it means nobody checked a button the traveller is being asked to press. A plan that
declares a mainland-China market must declare Google unavailable, and may then carry no Google
link at all. Mixing map providers is allowed but must be a decision: write why in
primary_map_exception_reason, a field that existed in the contract for three versions while no
check read it.trip.destination_coords — one object, or a list of them for a multi-city trip,
since each endpoint is judged against the nearest base. New York plus Los Angeles is 3,936 km
apart and Beijing plus Ürümqi 2,411, so a single anchor rejected trips that were perfectly real. The leg-length rule is relative, so it cannot see a
consistently reversed pair: writing lon,lat at both ends of a Las Palmas leg leaves the points
4.73 km apart instead of 4.70 while moving every pin to southern Africa. One absolute reference
turns every endpoint check from "do these two agree" into "is this where the trip is".venue_url must be a place
lookup keyed on the venue's real registered name. A plan shipped query=酒店自助早餐(Hotel Cristina by Tigotan) and query=Puerto de Ons for a restaurant Google lists as Restaurante
Ons — one searches for the phrase "hotel buffet breakfast", the other for a name that resolves
nowhere.route_map_scope: "multi_stop" prints the button as a full-day route. It is only true when the
URL carries every intermediate stop as a waypoint. Eight days of one plan claimed it while
carrying two endpoints; if you have no waypoints, the scope is primary_leg.When geography genuinely forces a long way round, say so in the segment's detour_reason and the
leg passes. The Grand Canyon rims are 18 km apart and 350 km by road; a Norwegian fjord crossing
runs 5.0x its straight line — and a leg whose endpoints pointed at the wrong pair of stops ran 5.1x.
No ratio separates those, so the author does, in one field.
check_plan_consistency.py now fails on the arithmetic rather than the wording: the straight-line
distance between a leg's two coordinate endpoints cannot exceed the distance_km that leg claims
(Taiwan→Gran Canaria is 12,537 km against a declared 6.2). It also lists every free-text endpoint
for you to open, because only a geocoder can tell a real name from a caption — read that list.
For each day, render an accessible, clearly labeled schematic route map (not for navigation), a verified full-day directions link, and a separate verified map button for every route segment (for example hotel → station, station → attraction). A route button must encode the actual endpoints and primary mode, never a POI page; mainland-China Amap buttons must use the documented uri.amap.com/navigation URI with from, to, and mode. Choose one researched primary mode rather than “metro/bus/taxi (choose one)”; show the alternative only as a fallback. Label each button with the actual provider (for example “在高德地图打开此路段”), plus only a checked alternative when useful. For self-drive, include the overall driving sequence, distance/time, likely toll/fuel/parking considerations, and rental-car links. For public transport, include the operator/line, boarding or exit instructions, transfers, walking burden, service caveats, time, fare basis, and fallback.
For every full sightseeing day, add researched lunch and dinner cards; arrival/departure days include the relevant realistic meal. Each card must name a specific venue, style, area, price per person, queue/reservation note, rationale, safe venue link, and a backup when material. A restaurant’s POI link is permitted only as a venue reference, never as a route button.
A venue with no quality signal is a taste assertion, not a finding — and the schedule is a claim about opening hours. Both halves stay inline because both failed silently on a delivered plan whose every gate was green. It shipped a dinner at El Chiringuito del Sur, which returns no listing on Google, TripAdvisor or any Canarian directory — it came from a blog listicle and does not appear to exist. It scheduled lunches at 13:15 and 12:30 at two restaurants that do not open until 20:30 and 20:00. It priced a farewell dinner at €55–90 where the venue bills €100+, and it keyed a map button on "Puerto de Ons" for a restaurant Google lists as Restaurante Ons.
So every dining card carries:
rating_value with its rating_scale (5 or 10 — a bare 4.4 is not comparable between Google and TheFork), rating_count, rating_source, rating_url, rating_checked_at — or rating_status: "none" plus a reason, which is the honest answer for a market stall or a hotel breakfast. The count is required beside the value because 4.8 from 12 reviews and 4.3 from 2,000 are different claims. Below 3.5/5 fails; below 4.0 is reported and needs a sentence saying why it still earns the evening.hours_status of verified or researched whenever the card names a time_window. Putting a meal on the clock is the claim that the venue is open then; unverified beside 13:15–14:30 is that claim with its evidence deleted, and it reads as researched to the traveller and as compliant to the gate. Verify the hours for that weekday or drop the venue.Below the floor is a decision, not a wall: write rating_below_floor_reason (or
guest_rating_below_floor_reason) and the card ships. The only place in town serving a dietary
need, a legendary stall whose score is all queue complaints, the village's one accessible room —
those are real answers. The reason has to be its own field rather than a sentence in the rationale,
because the message used to promise that escape while no code read it: the honest author who wrote
the justification was rejected anyway, and the one who flipped rating_status to "none" walked
straight through with the low score still in the card. Both halves are now enforced.
Hours have no equivalent escape, and the asymmetry is deliberate. A market genuinely has no single
score, so "none" is an honest answer about a rating. Nothing is an honest answer about hours once
the card names a seating time — a traveller standing at a closed door is not an information gap.
check_plan_consistency.py enforces all of this; it cannot tell you a venue is good, only that you never looked.
A hotel is judged the same way, and for longer. Somebody sleeps there for a week, so
guest_rating_value with its guest_rating_scale (Booking and Agoda publish out of 10, Google and
TripAdvisor out of 5 — a bare 8.5 means nothing without it), guest_rating_count and
guest_rating_source are required, or guest_rating_status: "none" with a reason for a property
too new to have reviews. Below 7.0/10 fails: on Booking's own published wording 7 is "good" and
6 is "pleasant", which is the polite end of the scale where the complaints begin. Between 7 and 8 is
reported rather than failed, and needs a sentence in selection_rationale saying what makes it
worth a week of nights. The score costs nothing extra — it is printed on the same page you opened to
read the price and check the dates are sellable.
Neither floor can tell you about the reviews underneath the average. A 4.3 from 3,000 diners hides 300 unhappy ones, and whether that matters depends entirely on what they were unhappy about: "slow service" is noise for this traveller, "a 20-minute walk from the nearest bus stop" is disqualifying for one with a stated walking limit. No gate can read that. Skim the recent negative reviews of anything you are about to recommend for a whole week or a farewell dinner, and if the complaints land on a constraint this traveller actually stated, say so on the card or pick something else.
A trip that sleeps in more than one place is a sequence, and the plan expresses it through fields it already has — one stay_group_id per stop, one leg_group_id per intercity journey, each day's route carrying the move. Read the multi-stop section of references/booking-html-output.md before planning one. The shape is asked once, on the intake form (destination_scope.trip_shape), because it cannot be derived: "one country" is equally true of one base and of five stops. It comes with two bounds the traveller sets — at most how many stops, at least how many nights in each — and without them "you decide" is unbounded and a multi-city trip becomes a different hotel every night. new_plan_skeleton.py --from-intake prints a TRIP SHAPE: line so an author who never opens the intake still sees it. Four gates follow, and each exists because the single-destination version stops working silently at two stops: comparable options are compared within a journey, never across journeys (Beijing→Shanghai and Shanghai→Beijing were counted as two options for one leg, so neither leg was ever compared, and the more legs a trip has the more confidently the gate reports a comparison nobody made), two stay groups may not claim the same night (every per-day check passes — each day's accommodation really does cover it — so only the pair is wrong), every jurisdiction the trip enters needs its own entry answer (entry_context is one object, so a three-country trip could carry Thailand's answer alone, cite an official source, pass, and print 「免签」 while being wrong about two of three — and a wrong entry answer is a denial of boarding, not a closed restaurant), and the map rule is applied per day rather than per page (one page-wide market told a Shenzhen+Hong Kong plan that its Hong Kong days must use Amap, which is not the tool for Hong Kong transit). Give every stop a jurisdiction — 「日本」, 「中国大陆」, 「香港」, 「申根区」 — because it is what both of those gates read. The page derives its spine from the stay groups and renders where the traveller sleeps and when that changes; do not build it from base_location, which is free text and has been found meaning "where today's activities are" on one plan, with four spellings for one hotel.
Read references/booking-html-output.md before researching booking links, using OpenCLI, or producing the page. Before the gates, run python scripts/check_plan_contract.py <plan.json>. It is not a gate and it decides nothing about the trip: it answers the one question the gates deliberately do not — is every key in this document a key the contract knows? — and answers it for the whole file at once. The gates validate by subject and stop at the first category that fails, which is right for them (a plan whose budget does not add up should not also be lectured about map links) and wrong for you: one measured Construction run spent thirteen round trips on field names alone — total_duration_minutes for duration_minutes, url for search_url, provider for platform, amount_low for per_person_low, day_number for planned_day, an outbound_itinerary written as a sentence where six fields belong — and every one of the thirteen was fixable in a single edit if anything had named all thirteen. It costs no network, reports each unknown key with the contract key it most resembles, and prints a worklist rather than a verdict. Two things it is not: it cannot tell a typo from a field you added deliberately, so an unrecognised key is a question; and it knows nothing about whether the plan is true or consistent, which is entirely check_plan_consistency.py's job. Its silence is not a pass. Run python scripts/check_plan_consistency.py <plan.json> --no-verification-yet before rendering — and swap that waiver for --verification <report.json> the moment the verification stage has produced a report. One of the two is now mandatory: the bare form used to exit 0 having run only the plan checks, so every check that reads the report (required domains for this plan's tier, both audits, each claims_checked pointer resolving, the report not stale and not bound to a different trip) ran nowhere at all, and an exit 0 is what an assistant reads. --no-verification-yet prints a NOT VERIFIED banner on stderr and records the gap; it is the honest form during the loop, never the form you deliver on. The gate decides in code what prose cannot be trusted to hold — route totals summed from their own segments, walking figures derived rather than asserted — and where the traveller stated a max_continuous_walking_minutes cap, every activity must declare on_foot_minutes, because an undeclared value and a measured zero are the same number and the gate used to reward the silence: a capped plan whose activities said nothing saved clean reading “20 min” while the same plan with the walking honestly written as 180 was refused. Writing 0 for a concert is an answer; writing nothing is not. No timestamp may still hold the skeleton's 1970-01-01 sentinel either, since a checked_at is the evidence somebody opened the page. Every ticket a day actually schedules declares a sale_opens_at — always_available, scheduled_release with the moment, at_the_door or sold_out_or_unavailable, plus one sentence of basis — because a ticket the traveller cannot be at a screen to buy is not a ticket they have, and the gate refuses a release that lands while the plan's own timeline still has them in transit; only tickets a day uses are held to it, and the sentence is what separates a rule somebody read from an always_available somebody assumed. The gate also computes every meal anchored to a stop on that day's route and checked against the venue's opening hours, calendar coverage without gaps over a window that runs forwards, a departure day that is a checkout rather than an extra night, budget totals that match the rows they claim to sum, every category the total claims to include actually itemised in the breakdown, no negative leg quietly cancelling a real one, and a day that never claims fewer interchanges than its own segments declare. Fix what it reports rather than arguing with it. Run python scripts/validate_trip_html.py <final.html> --plan <plan.json> before delivery. --plan is the primary path and the shortest thing to type: it derives the day count, the required booking-link types, the transport mode, the "not fact-checked" banner requirement and the gate-stamp requirement from the plan itself, and it refuses any of those flags typed beside it, because a hand-typed --expected-days 4 next to a derived 5 disarms nothing visibly — it just checks the page against a different trip. Only when there is no plan on disk do you supply them all by hand, and then every one is mandatory: --expected-days N, --require-booking-type flight|ground|hotel|ticket (repeatable) or --no-booking-types, --transport-mode self-drive|public-transit, and --require-unverified-banner or --assert-verified-without-plan. Omitting them used to be silent and print VALID: booking-ready HTML structure passed. with the day count, the booking-type rule, the car-link rule and the traveller-facing banner all switched off. --assert-verified-without-plan is the one escape from that banner and is named at that length so nobody reaches it by accident; it prints that the status was asserted rather than read. It also fails any button whose named provider is not the host its URL opens, and prints a note: for each button whose provider name has no matchable token — read those notes, because they are the only links the gate could not decide for you. Fix every reported issue. Then run python scripts/check_link_targets.py <final.html>, which follows every outbound button and reports where it actually lands: a page that names the right provider and carries every required attribute can still open a dead host or redirect onto someone else's site. It fails only on what survives any user agent — a hard 4xx/5xx or an off-domain redirect — and reports everything else as unverified, because a provider's answer depends on the agent that asked: the same Google Flights URL returns 200 unredirected to a browser and an unsupported page to a script, and an earlier check called that broken when it was not. Read every unverified line and resolve it by opening the link yourself; do not let a clean exit stand in for that. Do not output a booking-ready HTML page while essential dates, party size, budget basis, entry feasibility, or mobility mode remains unknown; return to targeted intake instead.
Both gates also take --json, and that is the form to iterate on. Prose is for reading; --json is for fixing. The prose report says WHAT is wrong and leaves you to open the plan and find WHERE — and the plan is by a wide margin the largest file in this loop, re-read once per fix cycle. Both gates print the same envelope on stdout — {ok, findings:[{rule_id, pointer, message}], rules:{rule_id: the rule's own wording, stated once}, notes} — with the exit codes unchanged, and every exit prints a body, including the refusals above, so a wrapper can append the flag to every invocation and still parse the one run that failed. Nothing is summarised: message plus its own rules[rule_id] entry is the prose line byte for byte, because the rationale is what lets you generalise to the case the rule never enumerated. notes rides along, so switching modes cannot silently drop the plan gate's NO VERIFICATION REPORT warning or the page gate's derived from plan: line — the two that say the run was unarmed, or which plan armed it. A fix is an edit at the pointer the finding names. check_plan_consistency.py gives a path into this plan — days[0].dining[1].venue_hours — already resolved against the plan you handed it; validate_trip_html.py gives line N col M in the page it just read. A null pointer means the gate could not place that finding, not that the field is missing — read it the second way and you go hunting for a hole nobody made. Most findings carry one: measured 2026-08-30 over the author's workspace, 846 of 928 plan findings across 15 plans and 89 of 124 page findings across the 12 pages that have a plan beside them. The rest are the findings with nowhere to point — one about the plan as a whole, one whose only handle is a value the plan holds in more than one place (the gate declines rather than guessing between them), or, on the page, markup that is missing and therefore has no line. Do not expect --json to be smaller: on those same 15 plans it runs larger than the prose it replaces, because it adds a resolved location to every finding. What it saves is the plan read, not the report.
The prose report states each rule once. Where a rule fires more than once, its first finding carries the full wording and ends [rule R1, stated here]; every later finding of that rule prints only what is specific to it and ends [rule R1, stated above], and a legend at the top of the block says so. Search the output for R1, stated here to read the rule — the two tags differ precisely so that search lands on the one line that states it. This is check_plan_consistency.py alone: validate_trip_html.py's findings are a sentence or two with no repeated rationale to suppress, so its prose stays one line per finding and its --json is where its rule ids and citations are stated once. The plan gate also declines the whole scheme on a report where the tags and the legend would cost more than the tails they save, so a short report prints exactly the bytes it always printed.
Those commands are the iteration loop, not the delivery. save_trip_deliverables.py re-runs almost all of it in-process on the plan you hand it: validate_plan, the intake_context provenance check, the same PLAN_CHECKS (it imports the tuple rather than keeping a copy, so the two cannot drift), check_verification when you pass --verification, then it stamps gates_passed, renders the HTML itself and runs the full validate_trip_html check set over what it just rendered — with the flags derived from the plan, so the banner and gate-stamp rules cannot be left off. A plan that saves clean has passed all of that at save time. So run the loop above to find and fix problems while you are still editing, and do not treat a clean manual run as the delivery, or a save as a reason to skip the loop: the save reports the first failure and writes nothing, which is a slow way to iterate.
The one thing it does not re-run is check_link_targets.py, because that is the only check here that needs the network. Nothing downstream will ever follow those buttons for you, so a link pass you skipped is a link pass nobody did.
When the user changes a constraint, first restate the delta. Trace dependencies rather than rebuilding everything:
Keep unaffected, still-feasible choices. Return a concise change log: retained items, replaced items, new total/risk, and any decision that needs user approval.
When the change moves the dates, run python scripts/replan_trip.py <plan.json> --shift-days N --out <new.json> rather than editing the plan by hand, and read references/replanning.md. Dates are the dangerous delta because almost every researched fact under them is keyed to a weekday, not to a date: opening hours, closure days, market days, Sunday retail law, a museum that shuts Mondays. A one-day shift silently invalidates all of it while the plan still looks complete. That is not hypothetical — a measured run moved the window by one day, redid the weekday map by hand, and introduced an off-by-one in every ticket and every anchor day index. The script rewrites only what is a pure function of the shift (trip and day dates, accommodation windows, dated booking fields, ticket day links), never prose — a sentence like "Saturday is the only full shopping day" becomes false when the dates move, and rewriting the weekday token inside it would turn a stale sentence into a confident lie. Everything it cannot safely recompute lands in replan_context.must_reverify, and check_plan_consistency.py refuses the plan until each entry is resolved. It also clears verification_status, because a plan whose dates moved was never verified on those dates.
Use templates/replan-request.json as the shape of that replan_context block.
When the traveller asks about a trip they already have saved — to reuse it, revise it, or ask
whether it still holds — run python scripts/audit_workspace.py --workspace "<workspace>" first,
and read the result before answering. Every gate here was written against the plan being built
at the time and nothing ever looked back: on a real workspace of eleven saved plans, only the most
recent passed, the rest carrying 25–126 findings each. Most were not newly-required fields but the
defects the traveller had reported — map endpoints that could not geocode, opening times asserted
with no evidence, walking legs whose implied speed was a run. Those pages are still openable and
say nothing. The tool reports and never edits, because re-plan, re-verify or discard is the
traveller's decision; surface what it found and let them make it. The default run prints a one-line verdict per
plan and nothing else; the findings themselves are behind --verbose, one plan at a time with
--plan <file>, and --json gives the same verdicts as a record per plan with its findings
counted by rule id. A count is not a diagnosis — open the findings for any plan the traveller is
about to reuse rather than reporting its number at them.
Before delivering a recommendation or plan, verify:
intake_context says which — with the traveller's own declining words when it was chat_fallback. Offering chat as an equal option, or switching to it because the form felt slower or a background command felt awkward, is the defect this field exists to make visible;entry_context; when the traveller stays home, entry information was neither requested nor treated as a blocker;validate_trip_html.py fails the page rather than leaving this to inspection;provider/map_provider/official_or_authorised_provider labels the button and is the destination it must resolve to, so a comparison platform belongs in round_trip_search_* or comparison_searches, never behind an airline's name. validate_trip_html.py fails the page on a mismatch rather than leaving it to a human clicking each button;booking-html-output.md#… means the rule was written down and not read;digital_travel_access kept to convenience preferences with no account, document or payment context stored anywhere;python scripts/save_discovery_deliverables.py <shortlist.json> --workspace "<workspace>" --intake <intake.json> and its Shortlist JSON: path was reported — with --no-intake only when no saved intake exists, and then said aloud, because that run never tested the winner against the traveller's own stated constraints and the saved file records the gap;python scripts/check_plan_consistency.py <plan.json> --verification <report.json> exits clean, so no route total, walking figure, meal placement, calendar date, or budget line contradicts the data it is derived from — and the report is checked against this plan while doing it. The bare form is refused, and --no-verification-yet is an iteration waiver, not a delivery state: a page delivered on it has had no report read at all;trip.destination_coords is declared so a reversed pair cannot pass; no day claims multi_stop scope without waypoints, and no transit URL carries waypoints at all (Google returns no route for those);validate_trip_html.py fails a card that prints none, because a rating stored and never shown is the same defect as a rating never gathered;rating_status: "none" with a reason), names the venue as its map provider indexes it, and has verified hours for the weekday it is scheduled on — a scheduled meal is a claim the venue is open;check_plan_consistency.py now compares the declaration against the URL for all of them, which it previously did for the dates alone: a delivered plan carried google.com/travel/flights?q=Flights+from+AMS+to+HKG+on+2027-04-17+through+2027-04-22 while declaring origin, destination, both dates and travellers prefilled, and passed this gate with zero findings — the dates were "in the URL" as words in a sentence, and the other three were never looked for. The traveller opened the button and got an empty search box, under a declaration that this could not happen. A field the plan declares about itself is an attestation, and an attestation records that a rule was claimed, never that it was followed — so wherever the artifact can be asked directly, ask it instead. kayak.com/flights/AMS-HKG/2027-04-17/2027-04-22/1adults passes because each field is its own path segment; a provider that only takes free text cannot honestly declare prefilled fields, and should declare none;consistency, completeness) on the full pass, and sights_and_hours, transport plus both auditors on the light tier, as check_plan_consistency.py computes it from the plan rather than as anyone declares it — with every claims_checked a list of pointers that resolve, and every wrong or misleading finding is either fixed in the plan or explicitly accepted by the traveller — a plan saved with --unverified carries verification_status: unverified and renders a "not fact-checked" banner on the page, so never describe such a page as booking-ready;scripts/replan_trip.py and references/replanning.md rather than a hand edit, every replan_context.must_reverify entry it raised is resolved, and the plan was re-verified — a shifted plan carries the old verification, which was never true of the new weekdays;