---
description: Stream S: land Mark's and Adam's structure review (URLs, Perfume, brand floor, markup, /structure, /todo), then drafts for rian
---

You are running **Stream S** of the Duty Free Professor soft-launch build, as an
autonomous session. Start by running `pwd`; it must be `/srv/apps/dutyfreeprofessor`, or stop.

This command is its own brief. Read first, in this order: `.logs/planning/streams/OVERNIGHT-RULES.md`
(binding), then `agents.md`, `brief.md`, build plan `.logs/planning/build-plan-2026-09.md` sections 2, 6,
7 and 9, `.logs/decisions-for-rian.md` (generated; read, never edit), the two newest entries of
`.logs/handoff.md` (both dated 2026-09-09; stop there), `main/docs/SEO.md`, `main/docs/CLIENT-SURFACES.md`,
and then the review itself: every comment with id 154 to 208, read from the app's own API on its
container port, `curl -s http://172.17.0.1:3149/api/discussion/feature-comments` (all of them carry a
`structure:` key). Read the comments in full before you change anything; they are the specification.
Comments 154, 156, 160, 173 and 185 are the transcriber's pointers, not the reviewers' words.

You are the only session in this working tree tonight, so every file is yours. The rest of the
overnight rules still bind: never deploy, never run a collection, never run a migration against the
live database, never `git push`, never touch `.env` or `.app.env`.

## What rian decided in chat on 9 Sep (record these before any other work)

1. **The placeholder on the launch domain will not be noindexed.** Rian is standing production up
   on DigitalOcean on 10 Sep, on the launch domain, behind a domain password. The cutover replaces
   the WP Engine placeholder, and the password keeps the new site out of the index, which is what
   Mark asked for ("I would not index this until as absolutely late as possible"). So:
   - Resolve `do-noindex-the-placeholder-site-on-the-launch-domain` with `--by "rian (in chat, 9 Sep)"`
     and this note: "Overtaken by rian's decision of 9 Sep: production goes live on 10 Sep on the
     launch domain behind a domain password, replacing the WP Engine placeholder, so the placeholder
     is never noindexed. Assumes the launch domain is dutyfreeprofessor.com as the plan states (rian
     wrote dutyfreeprofessors.com in chat); if production lands on any other host, reopen this item,
     because the placeholder would stay indexable."
   - File one issue for Stream E (`python3 main/scripts/items.py add --kind issue --by "Stream S"
     --owner E --blocks E4c --weight costly ...`) recording the consequences of a password-gated
     production host, so tomorrow's E session sees them: the same two sentences about the domain;
     IndexNow must not run and `INDEXNOW_KEY` stays unset while the host is gated; the sitemap and
     robots are moot until the gate comes off; the client surfaces (`/structure`, `/quote`, `/todo`,
     `/discuss`) stay reachable behind the domain password, which bears on
     `decide-client-surfaces-on-production` (rian's; do not resolve it); the password mechanism
     (Caddy `basic_auth` on the droplet's Caddy, or a Cloudflare Access rule on the zone) is E's to
     choose with rian present, not this session's, and since no plan document contemplates a gated
     production host the choice is a recorded deviation from the plan; and if the cutover slips, the
     noindex ask comes back.
2. **This session lands every change Mark's and Adam's comments call for that a session can make
   without rian**, records what it cannot, and drafts rian's replies without posting them. Rian
   deploys afterwards; nothing you change is visible to Mark or Adam until he does.

## How this session records progress

Rian asked for this session's work to show on `/plan`, so as a one-off: add a lane for yourself to
`import/progress.json` once, at the start, under the same lock `plan-set.py` uses, and never edit
that file by hand again (use `plan-set.py` for status):

```bash
python3 - <<'EOF'
import fcntl, json
p = "import/progress.json"
with open(p, "r+") as f:
    fcntl.flock(f, fcntl.LOCK_EX)
    d = json.load(f)
    if not any(s["key"] == "S" for s in d["streams"]):
        d["streams"].append({"key": "S", "title": "S · Structure review", "session": "/stream-s", "tasks": [
            {"id": "S1", "wave": "w2", "title": "Airport URL leads with the airport's common name (JFK code-first); every earlier shape 301s", "status": "todo", "paid": False, "due": "2026-09-11"},
            {"id": "S2", "wave": "w2", "title": "Review outcomes in code: the Perfume category; a brand page needs three products at two airports; the price of record leads the page and is the only price in the markup", "status": "todo", "paid": False, "due": "2026-09-11"},
            {"id": "S3", "wave": "w2", "title": "/structure rebuilt around the review outcome; /todo airport ask reworded; built rows flipped to live", "status": "todo", "paid": False, "due": "2026-09-11"},
            {"id": "S4", "wave": "w2", "title": "Drafts for rian: the nineteen airports, answers to the open questions, the frame for Adam's two documents", "status": "todo", "paid": False, "due": "2026-09-11"},
        ]})
        f.seek(0); f.truncate(); json.dump(d, f, indent=1); f.write("\n")
EOF
```

Then the overnight rules as written: `plan-set.py S1 doing "Stream S"` when you start a task, `done`
with a `--note` that says what proves it, `blocked` with why. Commit prefix is **`S:`**, one commit per
landed change (S2 is three commits). A CHANGELOG line under `## Unreleased` for anything Mark, Adam
or a shopper would notice; never bump the version. Run `../.venv-dev/bin/pytest tests -q` and
`main/check.sh` before every commit. Every `items.py add` carries `--by "Stream S"`. Delegate
mechanical edits (test updates from a precise spec, bulk text substitutions) to a `sonnet` subagent
and verify the diff yourself; never delegate the URL design, anything in identity or matching
rules, or any client-facing wording.

**Rehearsal database.** `dfp-devdb` (`127.0.0.1:5433`) is up today, but rian may remove it at any
time (item `do-dfp-devdb`). For any local run, restore the newest `backups/dfp-nightly-*.dump` into
a fresh `dfp_s` database on it, as earlier streams did with `dfp_b`, point `DATABASE_URL` there, and
drop `dfp_s` when you finish. If the container is gone, verify with the route tests alone and say so
in the task note and the handoff. Never point anything at the live database.

## The tasks, in order

### S1. The airport URL leads with the airport's common name

Running-list item `issue-url-changes-from-mark-s-review-airport-name-in-the-path` (blocks A13). The
agreed shape, from comments 155, 176, 196 and 197: the plural `/airports/` stays; the slug is the
airport's common name, then its code, then its city, so `/airports/heathrow-lhr-london`; JFK is the
one code-first airport, `/airports/jfk-new-york`, unless Adam names others (he has not). Category at
airport, when B6 ships, follows the same slug.

Constraints, all binding:
- **The IATA code stays the identity.** The words are for people, and never come from a request
  string. Resolve a slug in this order: (1) an exact match in the declared table gives the code;
  (2) else today's rule, the leading segment when it is three letters, which keeps every old shape
  and every undeclared airport working; (3) else any segment that is a key of the declared table,
  so a stale new-shape slug still finds its airport. Never treat a three-letter word as a code
  unless it is the leading segment or a declared key. Add a test that no declared slug's leading
  segment collides with another declared code.
- **The common name is declared data**: one table keyed by IATA in `main/app/services/urls.py`,
  mirrored exactly in `main/web/src/lib/urls.ts`, with a test that reads `urls.ts` and pins the two
  tables equal, the way `tests/test_seo.py::TestRouteInventory` reads `App.tsx`. Declare an entry for
  every location with an IATA code that is not catalogue-only (the rows `airport_locations` returns,
  plus the hidden ones; the nineteen first); the sources without an airport code have no airport
  page and need none. Drop the city where it repeats the name (`hong-kong-hkg`, not
  `hong-kong-hkg-hong-kong`); Paris is Extime, CDG and Orly together, so name it for Paris; an
  airport with no declared entry falls back to today's `<iata>-<city>` shape so nothing 404s.
- **Every earlier shape 301s to the new canonical**, keeping the query string: the bare code,
  `lhr-london`, `LHR-Heathrow`, any stale words. A hidden or unknown airport stays a 404.
- Update every caller and every mirror: `airport_path` and `parse_airport_slug` and their TS twins,
  `seo.head_for_airport`, `catalog_queries._airport_summary` and the dataset coverage, `indexnow`,
  the product price-table row link in `seo.py` and `PriceTable.tsx`, `AirportsPage.tsx`,
  `AirportPage.tsx`. Where the API already sends `path` (`AirportSummary`), use it and delete the
  client-side rebuild; the TS table then serves only `PriceTable.tsx` rows and
  `airportIataFromSlug` in `AirportPage.tsx`, unless you add a location path to the price schema,
  which is the cleaner route. Tests that pin today's shape and must move with it:
  `tests/test_seo_airport.py`, `tests/test_site_routes.py`, `tests/test_crawl_surface.py`,
  `tests/test_seo_brand.py`; add the redirect cases and the JFK exception as new tests, docstrings in
  the house form (the real record, what it cost).
- `main/docs/SEO.md` "URL scheme and redirects" states the new rule and the table's home;
  `main/web/src/lib/structure.ts` URL rows and page cards show the new shape (S3 does the rest of
  that file); the CHANGELOG line names an example.
- Verify locally: build the SPA (`npm run build` in `main/web/`), expose `web/dist` as `main/static`
  (a symlink is fine; `.gitignore` covers it; without it the page routes are not mounted), run
  `../.venv-dev/bin/uvicorn app.main:app` from `main/` against `dfp_s`, then curl the new path, each
  old shape, a filtered old shape, and `/airports/jfk-new-york`. Tear down the symlink and `dfp_s`
  after. Without the database, the route tests are the verification; say so.

When it lands, resolve the item with the commit, and add one `decide` for rian (`--by "Stream S"
--weight costly`, no `--blocks`): the full slug table for the nineteen, for him to confirm with
Adam, with the assumption "JFK is the only code-first airport" stated.

### S2. The other review outcomes that are code

Three changes, each its own commit, in this order:

1. **Perfume.** Resolved 9 Sep: "Family, navigation, URL and category vocabulary all become
   Perfume." In code, Fragrance is a **category**, not a family: the rule name in `taxonomy.py`, the
   `VERTICAL_OF_CATEGORY` key, and the stored `products.category` value. Rename the category to
   Perfume so navigation, filters, breadcrumbs and category links read Perfume; cologne and eau de
   toilette stay beneath it. The vertical `beauty` and its label `Beauty` stay, since nobody decided
   otherwise; so do the concentration attribute, collector walk names and normaliser stopwords. The
   stored value needs a new idempotent backfill, registered in `BACKFILLS` in `app/cli.py` (the
   existing `backfill categories` touches only uncategorised rows and will not do it), written and
   rehearsed against `dfp_s`, never run against the live database; the CHANGELOG line then says
   "After deploy: `backfill <name>`". The family pages `/alcohol`, `/perfume`, `/cosmetics` exist
   only as structure-page text; S3 renames them there.
2. **Brand page floor.** Resolved 9 Sep by Adam: a brand page exists when the brand has **three or
   more products stocked at two or more airports** (that rule alone; not "four products"). Read it
   as: three or more published products, and those products are priced at two or more distinct
   airports between them. Today `BRAND_PAGE_MIN_PRODUCTS = 2` in `catalog_queries.py` gates
   `_house_counts`, which feeds `list_brands`, `brand_page_slugs`, `brand_head`, the sitemap and the
   product API's `brand_slug`. Put the two-part rule in that one place so every consumer agrees; a
   thin brand is still a 404 with no sitemap entry and no link from product pages. State the reading
   in `SEO.md` (it still says two) and in the S1 decide item's assumption so Adam can correct it in
   one line. Update the tests. Rehearse on `dfp_s` and record the resulting brand-page count in the
   handoff, read from the database, never typed into a client-facing page.
3. **The price of record.** Item `issue-fix-the-offer-markup-so-its-currency-matches-the-page`
   (blocks A13). Decision (comments 188, 201): the shop's own price in its own currency is the price
   on the page and the only price in the markup; our conversion sits clearly second, labelled as our
   conversion with the date of its rate, and never enters the markup. Two halves. Markup: in `seo.py`
   the product page's combined `AggregateOffer` declares USD from our conversion while each nested
   offer is in its own currency; either the combined offer carries no price range, or one per
   currency; nothing appears in the markup that is not visible on the page in the same currency
   (airport and brand pages carry no aggregate offer; confirm). Page: in the price table
   (`PriceTable.tsx` and its `seo.py` mirror) the shop's price in the shop's currency leads each row
   and the converted figure sits second, labelled as our conversion with the date of its rate; if
   the table already reads that way, say so in the commit body, if not, make it so in the same
   commit. Pin the markup with a test built from a real mixed-currency product. Resolve the item.

### S3. Rebuild `/structure` around the outcome, and reword the `/todo` ask

Rian promised this on the page (comment 189: "Once you have both replied I will rebuild this page
around the outcome"). The page's content is `main/web/src/lib/structure.ts`; the layout is
`StructurePage.tsx`. Rules that protect the comments already on the page:
- **Never rename or remove a thread key** (`structure:urls`, `structure:p-airport`, `structure:q-family`
  and the rest). Comments attach by exact key; a renamed key orphans them. Change the text under a
  key; add new sections with new keys freely.
- **Never type a number `/api/stats` can supply.** Counts stay in the live strip. Rule numbers
  (three products, two airports) may be typed; page counts may not, including the 772 in comment
  184 and whatever S2 measures.
- House style for everything on this page: no em dashes; never "cheap", "free", or anything implying
  the code is the client's or can move without rian.
- `.logs/planning/site-structure-proposal.md` is the 3 Sep snapshot kept for provenance. Do not edit it.

What changes, section by section (keep the key, rewrite the text):
- **URLs and thresholds** (`URLS`, `THRESHOLDS`, the `thresholds` section, `MAP_HUBS`, `MAP_CROSS`,
  and the `path` on `p-airport`, `p-category`, `p-crossing`): the airport shape from S1; the category
  path is `/alcohol/whisky`, nested under the family, no `/categories/` anywhere, with rian's rule
  from comment 177 in one sentence (the generic word is dropped only where it displaces what people
  search, so `/brands/<slug>` stays and whisky lives under its family); filter parameters
  (`/products?category=`, an airport page's `?category=`) are for people and canonicalise to the
  clean page, so they stay, and the page says so; the product URL keeps its numeric id, with one
  sentence on why (uniqueness, and the redirect when two records merge); the family paths are
  `/alcohol`, `/perfume`, `/cosmetics`; the brand threshold is the S2 rule. The airport, brand and
  article page rows are `live` (0.33.0 shipped all three on 5 Sep); the new airport address row is
  `built` until rian deploys. `SCHEMA_INTRO` no longer says "deploying this weekend".
- **Airport page** (`p-airport`): Mark's direction from comment 200 replaces the old shape. Drop
  "what the airport is known for, food, lounges, terminal tips". The page is about the duty free
  situation at that airport: where the duty free shops are, by terminal, any specialty shops
  (comments 161 and 162: a named shop on the JFK terminal map, the specialty liquor locations at
  Changi), perhaps a map, and no filler of any kind. Shops and their locations become a first-class
  field, editorial for now, with a note that the data model does not yet hold a terminal or a named
  shop (item `issue-shops-and-terminals-within-one-airport-are-not-modelled`, which stays open).
  Keep the automatic fields. Reword `editorialLoad` to the new shape. Add Mark's later idea from
  comment 205, a "notable charts" section chosen by rule, as a later addition.
- **Brand page** (`p-brand`, the `brand-fields` section, and a note on `s-brand`): Mark rates these
  high value (comment 203: searches shaped "X brand duty free", room for some general brand writing,
  templated fields such as country of origin, founding date, worldwide sales). Add those as optional
  fields with their origin on `p-brand` and in `brand-fields`, so the page is designed with room for
  them, and note on `s-brand` that they enter the Brand markup only once they are visible on the
  page. State that brand-and-airport pairing pages are a seventh page type, pinned until a threshold
  and Adam's commercial rules exist (comments 159, 179), with rian's caution that paid placement
  decides prominence, never whether a page exists; item
  `issue-brand-at-airport-pages-a-page-type-the-structure-proposa` stays open, cite it rather than
  resolving it.
- **Product page** (`p-product`): sizes are stored as a value and a unit with millilitres derived, so
  70cl and 700ml already match; the display rule is open (see S4).
- **Schema** (`schema`, `s-product`): the price-of-record rule as decided, in one sentence, and what
  the markup will and will not carry after S2.
- **Launch scope** (`single-price`, `q-single`): the resolved position: launch on the comparable set
  plus travel exclusives, keep collecting everything, publish more as price history accrues; Adam
  frames the rest as a next release.
- **Perfume**, everywhere a reader sees the word: `q-family`, the `grouping` section, the
  `beyond-drinks` title and body, `p-family`, `MAP_FAMILIES`, `COMMITTED`, `URLS[0]`; the
  concentration field on `p-product` keeps its meaning.
- **Five questions**: each answered question keeps its key and gains a short "Settled, 9 Sep" line
  stating the outcome; `q-missing`'s line: price charts over time for products and categories are a
  display feature for after launch, every observation is already stored dated, so the history
  accrues from now (items `issue-price-charts-over-time-for-products-and-categories` and
  `issue-notable-price-trend-charts-on-airport-pages-chosen-algor` stay open, owner D). Add a new
  section, new key, titled for what is still open after the review, listing: which airports besides
  JFK are code-first (Adam); whether prices differ between terminals at any of the nineteen (Adam,
  comment 180); size wording on the page, one unit or the shop's own (either reviewer, comment 181);
  which page types ship 18 Sep (rian, the sentence already marked for him). Add a second new section,
  "Settled by the review", one dated line per decision, so Mark and Adam can see the page was
  rebuilt around what they said.
- `main/web/src/lib/review.ts`: the feature board card `airport-pages` still says "tips, lounges";
  reword to the new shape. `main/docs/CLIENT-SURFACES.md` still mentions a `SCALE` block and
  `structure:scale` that no longer exist; fix those lines.
- Resolve `issue-airport-pages-mark-wants-duty-free-content-only-not-gene` and
  `issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat` with the commit and what it did.

The `/todo` ask: row 9, "Airport write-ups", still asks Adam for "food, lounges, terminal tips".
Its content is `main/scripts/seed-todos.py`, matched on (owner, title). **Change only the `detail`
(and `due` if it must move); never the title**, or the row and Adam's comment on it are orphaned.
Reword to Mark's shape: where the duty free shops are by terminal, specialty shops, perhaps a map,
succinct, nothing about the airport that is not about duty free. The seed refreshes every seeded
row, so before running it diff the script's `ITEMS` against `curl -s http://172.17.0.1:3149/api/todos`
and confirm the only difference is your row-9 edit; if anything else differs, stop and file a `do`
for rian instead of running it. Then apply it with the command in the script's docstring
(`docker exec -i dutyfreeprofessor-app python - < main/scripts/seed-todos.py`) and confirm from
`/api/todos` that row 9 kept its id, its status and its comment. That is a content update, not a
migration; besides the plan lane, it is the one live write this session makes. Do not post on any
thread.

### S4. Drafts for rian, never posted

Write `notes/review-drafts-2026-09-10.md`, in rian's voice, house style, for him to send or post
himself. It must not be a comment on any surface, and you never post or edit a comment as anyone.
Contents:
1. **The nineteen launch airports**, for Adam (comment 191): the sixteen in `LAUNCH_AIRPORTS` in
   `structure.ts` plus Paris, Seoul and Singapore, each with its full name, city and code. Read
   names from the database, read-only (`docker exec dutyfreeprofessor-db psql -U dfp dfp -tAc
   "select iata, name, city from locations where iata is not null order by iata"`); `app.cli
   locations` gives visibility only. Note that the airports collected but not in the nineteen (for
   example Keflavik and Sydney, the Heinemann catalogue and the land-border shop) are collected, not
   public.
2. **Answers to the open questions** as rian would put them, each labelled with the assumption the
   build proceeded under: code-first airports (JFK only), terminal price differences (unknown; ask),
   size wording (one unit shown, the shop's own wording kept in the record).
3. **A frame for Adam's two documents** (comments 191, 195, 208). Try to read each with
   `curl -sL 'https://docs.google.com/document/d/<id>/export?format=txt' | head -c 400`; text means
   it is public, HTML means a sign-in page and the document is not readable. Never sign in, never
   request access, never ask rian for a login. If readable, give concrete feedback against Mark's
   direction (comment 200) and the reworded ask; if not, say so plainly and give rian the frame
   only. The category example (whiskey) answers `/todo` row 10; keep that feedback to length, tone,
   and what a category intro should and should not contain.
4. **A reply for `todo:9`** to Adam's unanswered question of 5 Sep about length and images, now
   answered by Mark's direction.

Mark S4 `done` on `/plan` with the file path as the note. Leave rian's two items open
(`do-send-adam-the-list-of-the-nineteen-launch-airports`,
`do-give-adam-feedback-on-his-heathrow-airport-profile-and-h`); name both in the handoff's "what
rian must do" with the file path, and mention there that Mark's `/todo` row 1 ("Comments on the site
structure proposal") is still open and is his or rian's to close.

### If time remains, in this order, and only after S1 to S4 are done or blocked

- **Size display** (item `issue-size-nomenclature-differs-by-country-700ml-against-70cl`, owner A):
  under the assumption above, show one consistent unit in the price table and the served product
  body, keep the shop's wording in the record; file the assumption as a `decide` first.
- **House style on the product page**: `tests/test_seo_body.py` still expects an em dash in a product
  title and an award line; remove the dashes from the served text and move the tests.

## What you never do in this session
- Deploy, or run `srv-gw deploy`. Say "green and ready" in the handoff.
- Touch the WP Engine placeholder, Cloudflare, DNS, the gate, or anything on the production host.
- Register an app client, change gating, or add a hostname to the app container.
- Build B6 (category pages), the terminals model, brand-and-airport pages, or price charts. All are
  gated or later; the structure page records them.
- Run a collection, a crawl, or a migration against the live database.
- Post, edit or delete a comment on any client surface, as anyone.
- Rename a thread key, a `/todo` title, or a running-list id.
- Edit `.logs/issues.md`, `.logs/decisions-for-rian.md` (generated), or `.logs/planning/site-structure-proposal.md`.

## Goal, and exactly when you are done

**Goal:** every change Mark's and Adam's structure review calls for that a session can make without
rian is landed to the verified-and-committed standard, `/structure` and `/todo` say what was decided,
`/plan` and the running list reflect it, and rian has his drafts.

**You are done when ALL of the following are true**, and not before:
1. Every one of **S1, S2, S3, S4** in `import/progress.json` is `done`, or `blocked` with a note
   saying why. (Check with `python3 -c "import json;d=json.load(open('import/progress.json'));print({t['id']:t['status'] for s in d['streams'] for t in s['tasks'] if t['id'] in ['S1','S2','S3','S4']})"`.)
2. In `import/items.json`, each of `issue-url-changes-from-mark-s-review-airport-name-in-the-path`,
   `issue-fix-the-offer-markup-so-its-currency-matches-the-page`,
   `issue-airport-pages-mark-wants-duty-free-content-only-not-gene`,
   `issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat` and
   `do-noindex-the-placeholder-site-on-the-launch-domain` is resolved, or the S task that owns it
   is `blocked` with a note naming what only rian or Adam can supply and a `decide` item carries
   that question. (Check with `python3 -c "import json;d=json.load(open('import/items.json'));print({i['id']:bool(i.get('resolved')) for i in d['items'] if i['id'] in ['issue-url-changes-from-mark-s-review-airport-name-in-the-path','issue-fix-the-offer-markup-so-its-currency-matches-the-page','issue-airport-pages-mark-wants-duty-free-content-only-not-gene','issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat','do-noindex-the-placeholder-site-on-the-launch-domain']})"`.)
3. `../.venv-dev/bin/pytest tests -q` passes and `main/check.sh` prints "checks passed" on the
   current tree.
4. Every landed change has its own commit prefixed `S:` (`git log --oneline | grep "^[0-9a-f]* S:"`).
5. `notes/review-drafts-2026-09-10.md` exists with the four parts above.
6. A handoff entry for this session is at the top of `.logs/handoff.md` (25 lines at most: what
   landed and how it was verified, what is blocked and why, what is next, what rian must do,
   including "green and ready" and the after-deploy commands if any backfill rides), and
   `/checkpoint` has been run.
7. Anything only rian or Adam can decide is on the running list with the assumption you proceeded under.

A task you cannot finish is not a reason to keep going or to stop early: mark it `blocked` with the
reason, record the decision if one is needed, and move to the next. If all four are done or
blocked, finish 3 to 7 and stop. If you have been going for eight hours, mark every unfinished task
`blocked` with the note "eight-hour cap; resume with /stream-s", write the handoff, run
`/checkpoint`, and stop.

## The goal line (rian pastes this after the first turn ends)
Start the session with `/stream-s`. When that first turn ends, paste this one line; the evaluator
then keeps the session working, turn after turn, until the condition is met or judged impossible:

```
/goal Stream S of the Duty Free Professor build is complete: in import/progress.json every one of the task ids S1, S2, S3, S4 has status done, or blocked with a note saying why; in import/items.json the items issue-url-changes-from-mark-s-review-airport-name-in-the-path, issue-fix-the-offer-markup-so-its-currency-matches-the-page, issue-airport-pages-mark-wants-duty-free-content-only-not-gene, issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat and do-noindex-the-placeholder-site-on-the-launch-domain are each resolved, or the S task that owns each is blocked with a note naming what only rian or Adam can supply and a decide item carries that question; main/check.sh has printed "checks passed" on the final tree; every landed change has its own commit prefixed "S:"; notes/review-drafts-2026-09-10.md exists; a handoff entry for this session is at the top of .logs/handoff.md and /checkpoint has been run; anything only rian or Adam can decide has been added to the running list with main/scripts/items.py. Or stop after 120 turns.
```
