# CLAUDE.md

Standing rules for the BidBrain project. Read this at the start of every session and keep to it. This is the rulebook. The phased plan, the build order and the kickoff prompts live in BidBrain\_Build\_Brief.md. When a decision is made or the architecture changes, update this file so future sessions stay consistent.

---

## What this project is

BidBrain is a daily car buying assistant for Really Easy Car Credit. Each day it reads the new stock on Motorway and Carwow, discards anything that breaks Steven's rules, works out the most he should pay for each car that survives, and shows them on a private web page that Steven and his team open on any device. Steven places every bid himself.

---

## Golden rules, never break these

1. Never place a bid or take any action on the platforms. BidBrain recommends only. Steven enters every bid himself.  
   - Exception, approved by Steven 2026-06-09, PENDING IMPLEMENTATION: BidBrain may add a car to, or remove it from, the Motorway and Carwow shortlist/watchlist when Steven stars or unstars it in the app. This is a two way sync, shortlist only. It must NEVER place, change or retract a bid, and never take any other action on the platforms. This is the only permitted platform action. Bids stay entirely Steven's.
2. Never use a platform API. Read the logged in screens, use the reports the platforms already provide, and read local files. There is no API for anything in this project.  
3. When you cannot be certain which engine a car has, exclude the car. Play safe, every time.  
4. Never guess a value. If a price, valuation or detail cannot be read with confidence, hold that car back and flag it rather than pricing it on a bad number.  
5. Fail loudly. If a page has changed or a read breaks, stop and tell Steven clearly. Do not carry on and quietly produce wrong numbers.  
6. Keep Steven's purchase, sales and prep data local on the Mac Studio. Do not expose his margins or buying logic to anyone without the private link.  
7. Any text the app shows on screen, or any content it generates, must avoid hyphens and dashes. Use commas or full stops instead. This is Steven's house style.

---

## The buying brain

This logic does not change without Steven's explicit say so.

### Pricing a car

1. Look up the Glass's retail value and the Cazana retail value.  
2. Add 15 percent to each. These are the calculated Glass's value and the calculated Cazana value.  
3. Choose which governs. If the calculated Glass's value is over 10,000 pounds, use the calculated Glass's value. If it is under 10,000 pounds, use the higher of the two calculated values.  
4. Recommended maximum bid is the governing value minus 3,000 pounds.  
5. The 3,000 pounds is a flat spread on every car, whatever the price. It covers prep, buyer fees, VAT on the margin, transport and profit. Real profit is what is left after those, which the learning loop measures.

Glass's retail and Cazana retail are the only two figures that need a separate lookup. Reserve price and CAP Clean are on the auction listing and are read from the page.

### The hard gate, all must pass

- Mileage under 90,000  
- Reserve price under 11,000 pounds  
- Age between 2 and 10 years  
- Within 210 miles of NE3 5HE  
- Condition grade 1, 2 or 3 only, never 4 or 5  
- 4 previous owners maximum  
- Auction listings only. Steven only ever buys at auction, never a buy it now listing. This applies to both Motorway and Carwow. Buy it now listings are discarded before pricing.

### How banned engines are detected from platform data

The banned engines are families (TSI, EcoBoost, PureTech etc.) but the Motorway CSV often does not print the family name, only make, engine size and fuel. So banned_engine in pricing.py detects in two ways: by family tokens in the engine and trim text (the original way), and by make plus litres plus petrol fuel (LITRE_BANS), with year limits where the ban is year specific (Ford 1.0 EcoBoost 2014 to 2019, Ford 2.0 EcoBoost pre 2019). This deliberately errs towards excluding petrol turbos, which the brief intends. Diesels are unaffected. Banned make matching also handles Motorway splitting "Land Rover" into make "Land" with the rest in the model, and catches Range Rover. Proven on a live list: re running the rules over a day's gate passers removed 35 more banned engine cars that the text only check had missed.

### Decisions on engine detection edge cases (Steven, during the live review):
- Vauxhall and Opel 1.4 Turbo is also detected from the "1.4T" badge style, not only the word Turbo or the codes A14NET and B14NET. Naturally aspirated 1.4 Vauxhall (for example Corsa Energy 1.4) is not the banned turbo and correctly stays.
- Ford 1.0 EcoBoost ban stays at model years 2014 to 2019 as signed off. Cars from 2020 on are not auto rejected even though the wet belt ran on, Steven culls those by hand if he does not want them.

### Classes Steven does not buy, added during the live review (signed off)

Instant rejection, same as the bans. Detected from the platform data (the Motorway CSV gives Transmission, Fuel and Body type, carried on the car as transmission, fuel, body_type). Lives in class_exclusion in pricing.py.

- CVT gearboxes only. Ordinary automatics and semi automatics are fine, only CVT is excluded.
- Electric and hybrid of any kind: EV, plug in hybrid, full hybrid and mild hybrid (MHEV). Petrol and diesel only.
- Vans and commercials: body type Panel van, or a van model name (Transit, Connect, Partner, Berlingo, Caddy, Combo, Kangoo, Doblo, Expert, Vivaro, Traffic, Transporter, Scudo, Proace, NV200, Rifter).
- Jaguar is now a banned make, alongside Land Rover, Volvo and Jeep.
- Small city cars with CAP clean over 5,000 pounds. Below 5,000 they are fine. Small city car is matched by model name (SMALL_CAR_TOKENS: Aygo, 108, C1, up, Citigo, Mii, i10, Picanto, Panda, Twingo, ForTwo, ForFour, Celerio, Alto, Ignis, Adam, Viva, Mirage, MG3, Sandero, Spark, KA) plus Fiat 500 and Abarth 595. This token list is Steven's to refine.

Second round of review rules, from Steven's per car notes (signed off):
- More banned makes: MG, Mitsubishi, Infiniti (now in BANNED_MAKES alongside Jaguar, Land Rover, Volvo, Jeep).
- Banned models outright (BANNED_MODELS): Suzuki Swift, Fiat 500.
- No convertibles (body type Convertible).
- No automatic Fords. Ford with an Automatic or Semi automatic gearbox is excluded. This is on top of the global no CVT rule.
- Wheelchair access conversions are vans: Horizon and Independence model names added to VAN_MODELS.
- Value caps (VALUE_CAPS), will not buy when CAP clean is over the limit: Ford Fiesta over 7,000, any Citroen over 7,000, SEAT Ibiza over 7,000, Kia Stonic over 7,000, any Mazda over 7,000, and any MINI over 7,000 unless it is a Countryman or Clubman.
- Wheel trims versus alloys: Steven will not buy a car on steel wheels or wheel trims (must have alloys), but this is NOT enforced automatically because the platform data does not reliably state the wheels (the Motorway CSV equipment field lists alloys for almost none). Left as a visual check, or a future per car detail page read. Do not try to filter on the equipment field, it wrongly excludes nearly everything.

Banned outright, instant rejection

Engines: 1.2 PureTech, Ford 1.0 EcoBoost wet belt 2014 to 2019, Ford 1.5 EcoBoost, Ford 2.0 EcoBoost pre 2019, VW and Audi 1.5 TSI EA211 evo, Hyundai and Kia 1.6 Gamma and Nu GDI, Nissan 1.2 DIG-T HR12DDR, Renault and Nissan 0.9 TCe, Renault 1.2 TCe H5Ft, Vauxhall and Opel 1.4 Turbo A14NET and B14NET, Fiat and Alfa 0.9 TwinAir, Jaguar and Land Rover 2.0 Ingenium petrol, Audi and VW 2.0 TFSI EA888 Gen 2, Audi and VW 2.0 TFSI EA888 Gen 3 early units, Audi and VW 1.8 TFSI EA888, Fiat 1.4 MultiAir 500 and Tipo, Vauxhall and Opel 1.0 Turbo B10XFL.

Makes: Land Rover, Volvo, Jeep.

Uncertainty drops the car. This correctly removes most older Audi and VW 2.0 and 1.8 TFSI petrol turbos, which is intended.

### Preference, not a knockout

Full service history is preferred. Cars without it still appear, flagged as a lower quality buy.

### Repeat appearance flag, decided by Steven

A car coming round the auctions repeatedly is a warning sign, so flag it. Every day, record every car seen on each platform with that day's sale date. When a car appears again, highlight on its listing how many previous times it has been seen, counted per platform, within a rolling 30 day window.

- Identity is the registration, normalised to uppercase with no spaces. A car with no readable registration cannot be tracked for repeats, so it carries no repeat flag. The few Carwow and Motorway cards without a plate fall into this.
- Count previous appearances only, not today's. Example: a Mini seen on Motorway Monday, Tuesday and Thursday, shown to Steven on Friday, reads as 3 previous Motorway sales. If the same car was then on Carwow Saturday and Sunday, the next time it appears in any auction it reads as 3 previous Motorway and 2 previous Carwow.
- If the same car appears on both Motorway and Carwow on the same day, highlight that too, for example "in both Motorway and Carwow today".
- This is a highlight on the card, not a gate. A repeat car still prices and shows as normal, just flagged.

### Out of scope

Ignore finance commission, warranty, GAP, paint protection and admin fees. That is Steven's department. The recommendation is purely the 3,000 pound rule.

---

## Daily order of play

1. Before the auctions, pull the Clickdealer in stock list and count how many of each model are in stock now.  
2. Read the new daily stock on Motorway and Carwow. Capture registration, make, model and derivative, year, mileage, owners, grade, reserve, CAP Clean, location and distance from NE3 5HE, service history status, VAT qualifying status, photos and the listing link.  
3. Apply the gate and the bans. Discard failures.  
4. For survivors, look up Glass's and Cazana retail and work out the maximum bid.  
5. Annotate each car with its history from Really Easy Car Credit's own sold data, its current stock count flagged as gap, low or too many, and a VAT qualifying flag where it applies. A VAT qualifying car is still priced by the normal rule, just flagged.  
6. Sort cheapest reserve first.  
7. Publish to the private web page.

### When the read can run

Both Motorway and Carwow close their daily sale at 3:30pm and do not show the next day's stock straight away. Motorway shows tomorrow's stock only after 4:30pm, Carwow only after 5:00pm. Between 3:30pm and 5:00pm there is nothing to read. So the scheduled read fires after 5:00pm to catch both, or alternatively early the next morning since the stock stays up overnight. Never run it between 3:30pm and 5:00pm.

---

## Data sources, all through the Mac Studio, no API

- Motorway and Carwow daily stock: read from the logged in dealer screens. Reserve and CAP Clean are on the listing.  
- Glass's and Cazana retail: looked up per car in Steven's logged in accounts.  
- Clickdealer in stock list: pulled daily for the stock count.  
- Clickdealer sales margins: the monthly report, downloaded as CSV on the 10th for the previous month. Ignore the totals line at the foot and read only real car rows.  
- Clickdealer SIV reports: after the monthly CSV, walk each sold car by stock number and capture prep costs by type. The Studio does this, not Steven.  
- Later, the Clickdealer sources are swapped for DealerKit. Same shape of data.

Browser sessions are logged in once and kept alive. Steven re authenticates only when a session expires.

---

## What the app learns, per model

- Real average days to sell  
- Real prep cost, total and split by type such as tyres, bodywork, mechanical and MOT  
- Whether the 3,000 pound spread survived after prep  
- How Motorway compares to Carwow on days to sell and margin

These drive the history notes on each daily card.

---

## Architecture conventions

- The Mac Studio runs the engine and serves the page. It is always on.  
- Keep a single local database on the Studio for learned model figures, daily stock snapshots, daily shortlists and ingested sales history.  
- Use tools that run reliably on macOS. Favour approaches that survive small changes to platform pages.  
- The page goes on a real web address through a private secure tunnel, reachable by an unguessable link, no password, not indexed by search engines.  
- The project master copy lives in a private online store so Steven can edit from the Studio or his laptop and the Studio serves the latest version.  
- Schedule the daily run and the monthly run to fire on their own.

Record the specific tools, file paths and the live web address here as they are decided, so future sessions know where everything is.

Decided so far (Phase 1):
- Language and tools: Python 3, standard library only. SQLite via the built in sqlite3 module. No third party packages, so nothing to install and less to break on macOS.
- Project root: /Users/stevendouglas/BidBrain (moved off the Desktop in the interactive build, because macOS protects Desktop, Documents and Downloads from background launchd jobs with an Operation not permitted error, so the scheduled run could not read the files there. A plain home folder like ~/BidBrain is not protected. The code uses paths relative to itself so the move was clean, and the saved browser logins in data/browser_profile and the state files moved with it.)
- Local database: data/bidbrain.db (SQLite). Tables: cars, assessments, plus learned_models, stock_snapshots and sales_history laid out ready for Phase 3 but not yet written to.
- The buying brain lives in bidbrain/pricing.py and is covered by test_pricing.py.
- Hand entered cars for Phase 1 are read from data/cars.json.
- The cockpit page is generated as cockpit.html by build.py. serve.py runs a local only server on http://localhost:8765 when a served page is wanted.
- Live web address and private tunnel: not chosen yet, that is Phase 2.

---

## Project status

Update this section every session.

- Current phase: Phase 2 in progress. Both platform readers work live: Motorway and Carwow stock is read off the logged in screens, auction only, and fed through the brain. Still to do in Phase 2: Glass's and Cazana valuation lookups so cars get a real recommended bid, wiring the readers into the brain and the cockpit page as one daily run, the detail page read for owners and exact engine where a banned petrol variant exists, and the private web address via a Cloudflare quick tunnel.
- Phase 2 decisions:
  - Browser automation: Playwright with its own saved Chrome profile, so logins stay alive for the unattended daily run. Installed into the project.
  - Private web address: Cloudflare quick tunnel to start. Unguessable address, no account or domain needed, link changes on restart. Swap to a stable named tunnel later.
  - Glass's and Cazana: Steven has confirmed both are content with automated reads through his own login. The lookups will be built.
  - Readers are built against the live logged in pages, never against guessed HTML. Site by site, simplest first.
  - Motorway: logged in, saved profile working. Dealer stock list is https://pro.motorway.co.uk/vehicles. The reader is bidbrain/readers/motorway.py, proven against a real captured page: it reads reg, make, model, derivative, year, mileage, fuel, distance, grade, reserve, photo and listing link from each card. CAP Clean, owners, service history, VAT and exact engine are not on the list card and need each car's own page, still to build. Tested live on 35 real cars, gate culled 12, 23 passed the list level checks.
  - Carwow: dealer stock list is https://dealers.carwow.co.uk (it redirects to a marketing page until logged in). The reader is bidbrain/readers/carwow.py, proven against a real captured page: it reads reg, make, model, derivative, year, mileage, fuel, service history, reserve, CAP value, grade, distance, photo and listing link from each card. Each card carries data-listing-state, and the reader keeps only auction states, enforcing the auction only rule. Tested on 30 real auction cars, all fields read.
  - Carwow login persists in the saved profile, confirmed by a fresh browser reading the live auction stock end to end. The real stock URL is https://dealers.carwow.co.uk/dealers/listings/filtered/stock and the login form is https://dealers.carwow.co.uk/dealers/login. Gotcha: the site root https://dealers.carwow.co.uk/ always redirects to a marketing page even when logged in, so never test login state against the root, use the stock URL.
  - login.py now opens straight to each site's login form, writes data/inspect/<site>_status.txt and a live screenshot <site>_live.png each second so the session can see the window, auto captures when it reaches the logged in dealer area (host correct and not the login path), snapshots all tabs, and saves a session state file via storage_state as a safety net. It also auto clicks a Log in link if a dealer URL bounces to marketing.
  - Both readers run live and headless via browser.open_reader_context, which uses a saved session state file if present, else the persistent profile. Motorway and Carwow both proven reading live.
  - Glass's: login at https://uk.glass.co.uk/auth/login, ticking "Don't ask for 30 days" keeps the session alive for the daily run. Login persists, saved via storage_state to data/glass_state.json. Lookup is by registration plus mileage. On a valuation result page (uk.glass.co.uk/valuations/<uuid>) the value the brain wants is GLASS'S RETAIL, which reads cleanly from the element id avBasicRetailPrice, attribute data-price (a plain integer, for example 9085). The page also shows Glass's Trade and a separate Live Retail Price, do not confuse those with Glass's Retail. The /valuations page is the history of past valuations with a VRM filter column and a Bulk valuation option that may suit valuing the whole daily shortlist at once. Bulk valuation is a file upload batch job (Add bulk valuation, upload a file of regs, it processes then you export), table columns USER, CREATE TIME, NAME, STATUS, VEHICLES, PROCESSED, ISSUES, FILE NAME. Not yet confirmed whether the export returns Glass's Retail rather than the live price, would need a small test job in Steven's account, get his ok first. Plan: build single reg at a time first since the value location is known, treat bulk as a later speed up. The whole Cazana side is not started yet.
  - Glass's lookup BUILT and proven live. bidbrain/readers/glass.py: lookup(playwright, reg, mileage) enters the reg in #plateNumberInput and mileage in #mileageInput on uk.glass.co.uk, clicks Go, handles the /identification/valuations chooser (clicks the single matched ag-grid row, and if more than one genuine edition is offered it returns None to hold the car back, never guessing), opens the result, clicks Show more, and reads Glass's Retail from #avBasicRetailPrice data-price. Tested: PN68ZWX returns 9085 as expected, a fresh reg VF17OML returns 9032. Readers use the saved glass_state.json session, which persists. Note: login.py opens the persistent profile which can show logged out for Glass's even though the storage_state session is good, so always run readers via open_reader_context, and if Glass's ever asks to log in again, run python3 login.py glass and log in ticking Don't ask for 30 days.
  - Repeat appearance flag: data layer built and tested. db.py has a sightings table (reg, platform, sale_date, unique per car per platform per day), record_sightings(cars, platform, sale_date) to log each daily read, repeat_summary(reg, sale_date, 30) to count previous distinct sale dates per platform inside the 30 day window plus same day cross platform, and repeat_flag_lines(summary) for the card wording. Verified against Steven's Mini example (3 previous Motorway, 2 previous Carwow, same day both caught, old sightings drop out). Still to wire: call record_sightings in the daily run and show repeat_flag_lines on each card.
  - Cazana lookup BUILT and proven live. Cazana is now branded Percayso, login at https://trade.percayso-vehicle-intelligence.co.uk/. bidbrain/readers/cazana.py: lookup(playwright, reg, mileage) fills input[name=value] with the reg and input[name=mileage], presses Enter, lands on /companion/search/<uuid>, and reads the headline Retail value. The page has no stable id, so it finds the Retail block by the fact it uniquely contains the text Retail franchise, then reads the right hand amount. It deliberately takes the headline Retail, not the franchise, independent or supermarket breakdowns, and not Trade. Tested: PN68ZWX returns 11251, fresh reg VF17OML returns 9688. Login saved to cazana_state.json, persists.
  - End to end pricing proven on a real car: Skoda PN68ZWX, Glass's 9085 and Cazana 11251 live, governing value 10448 (Glass's, over 10k), recommended max bid 7448. Both valuation sources and the brain work on live data.
  - Daily run WIRED and proven on live data. daily_run.py reads Motorway and Carwow live, records every car to sightings for the repeat tracker, applies the list level gate, opens each Motorway survivor's page to read owners and engine via motorway.enrich_from_detail (which parses __NEXT_DATA__, wait_for_selector must use state=attached for the script tag, that was a bug), runs the full brain, looks up Glass's and Cazana for gate passers, prices them, adds repeat flags, and writes cockpit.html. Test run (python3 daily_run.py --test 3) valued the 3 cheapest of 19 survivors: Nissan Qashqai reserve 6043 max bid 7082, Ford Focus reserve 5806 max bid 5681, Peugeot 308 held because Glass's would not read (held not guessed, correct). Full run python3 daily_run.py values all survivors.
  - Fixes after Steven's review of the first daily page:
    - Wrong bid links: each Motorway card is wrapped by its own anchor like <a id=vehicle_card_ID href="/vehicles/ID"> sitting just before the card div, and the old split paired a card with the next card's href. motorway._card_blocks now splits on that wrapping anchor and takes the href from it. Verified reg to link against the live DOM.
    - CAP missing: CAP is not on the Motorway list card, it is in the detail page __NEXT_DATA__ as a price entry with priceSource CAP. enrich_from_detail now reads it. Verified per car (it is normal for two different cars to share a CAP value).
    - Auction only locked into the source URL: motorway stock_url is now .../vehicles?listType=auction.
  - Motorway saved search DONE. Steven has a saved search titled "Normal search" that encodes his brief. Applying it gives this URL, now baked into motorway stock_url: /vehicles?ageFrom=2&ageTo=10&displayPriceTo=11000&maxDistance=210&mileageTo=90000&numericGrade=1&numericGrade=2&numericGrade=3&previousKeepersCountTo=4&sellerType=private&listType=auction. So the daily read starts pre filtered to his brief (age, price, distance, mileage, grade 1 to 3, owners up to 4, auction, private sellers). If he edits the saved search, re capture this URL by applying "Normal search" once and copying the address bar. With this, 34 of 36 read cars pass the list gate instead of 21 of 36.
  - Rejected cars are hidden on the daily page. render_page now takes show_rejected, daily_run passes show_rejected=False. Held back cars still show (they pass the rules but could not be priced). Rejected cars are still saved to the database.
  - Known item: the read currently takes the first page of the stock list (about 36 cards). If the saved search returns more than one page, later pages are not yet read. Add pagination if the daily list is being truncated.
  - Limitation seen live: a car with no registration cannot be valued by reg lookup, so it is held. Cars without a plate also carry no repeat flag.
  - Carwow pricing DONE. carwow.enrich_from_detail(page, car) opens each Carwow car's own page and reads Former keepers (owners) and VAT qualifying as label then value lines. daily_run now reads Carwow, records sightings, list gates, enriches candidates, and prices them pooled with Motorway. Proven: a run read 29 Carwow cars, 3 passed the list checks and were enriched, both platforms assessed together.
  - Remaining in Phase 2:
    - Carwow brief filter DONE. Steven has a Carwow saved filter, id 1741. Applying it gives ?saved_filter_id=1741, now baked into the carwow stock_url. With it, 20 of 29 read Carwow cars fit the brief (was 3 unfiltered) and Carwow cars now price and pool with Motorway. If he edits the saved filter, re capture the saved_filter_id by applying it and reading the address bar.
    - Pagination DONE, both platforms.
      - Motorway: the stock page has no page param and does not infinite scroll, but it has a Download CSV which is the whole filtered list in one file. motorway.read_export(playwright) clicks Download, picks "Filtered vehicles", captures the CSV and parses every row. The CSV carries Buying type (Live sale means auction), VRM, Make, Model, Year, Mileage, Number of owners, Service history, Exterior grade, VIN, Fuel, Engine size, Reserve price, CAP clean value, Location and the vehicle link. This replaces card scraping AND the per car detail reads (owners, engine, CAP all in the CSV). Proven reading the full filtered list (about 245 to 286 cars). The CSV gives Location not exact miles, but the saved search enforces the 210 mile limit, so the brain trusts the filter for distance: assess(car, today, assume_distance_ok=True) and gate_failures skip the distance check, set by daily_run for Motorway cars via _assess. Engine for the ban check is built as litres from Engine size plus the Model text (which carries TSI, TFSI, EcoBoost, PureTech, DCI etc.) plus Fuel. The brain still re checks mileage, reserve, age, grade, owners from the CSV and applies the bans. The old read_live and enrich_from_detail remain but are no longer used by the daily run.
      - Carwow: paginates with &page=N. carwow.read_live now loops pages, accumulating auction cars and stopping when a page shows no new listings. Proven against the filtered list (about 136 vehicles across about 5 pages).
    - Carwow listing state varies by time of day. Each card has data-listing-state. The next day auction stock reads as waiting_for_auction (after 5pm and overnight). Between sales the same cars show as second_chance_quotes, which are not the auction, so the reader correctly returns zero auction cars then. This is why the read must run after 5pm. parse_listing keeps only states containing "auction".
    - The Cloudflare quick tunnel for the private web address.
    - Optionally investigate why some Glass's lookups return nothing (likely a multi edition identification case, currently held which is safe).
  - Cockpit redesign, decided by Steven. Card hierarchy, most prominent first: governing value (hero, biggest), then max bid (green, with an over or under reserve pill), then CAP Clean, then platform. Platform is a coloured pill on the photo top left (Motorway blue, Carwow purple). Condition grade is a coloured circle on the photo bottom right, grade 1 green, 2 lighter green, 3 amber, 4 and 5 reds. The "governing value less the 3000 spread" line was removed. Reserve, mileage, year, owners, distance are small chips, and the Glass's and Cazana breakdown is tucked in an expandable Valuation detail. Lives in bidbrain/render.py.
  - daily_run now caches each run to data/last_run.json and supports python3 daily_run.py --render-only to rebuild cockpit.html instantly from the last run, so the design can be tuned without re valuing.
  - Cockpit tweaks after Steven's review: hero label is "Retails for" (was Governing value). Grade 2 circle is yellow with a dark number. Cards reserve two lines for the name (header h2 min-height) and the bid button is pinned to the bottom (margin-top auto) so buttons line up across a row. Real platform logos sit in the top left pill: Motorway black wordmark on yellow, Carwow cyan flower and wordmark on white. Logos are embedded as data URIs read from assets/mw_logo.datauri and assets/cw_logo.datauri by render.py (sources assets/mw_src.png and assets/cw_src.png, cropped with sips). The Carwow logo was captured from Steven's clipboard via osascript as «class PNGf» since pasted images are not files.
  - Diesel rule, decided by Steven: a car that is plainly a diesel with no banned diesel variant passes without needing the exact engine code. Only chase the exact engine on the detail page where the model also has a banned petrol variant. Keeps real diesels in and the uncertainty rule focused where it matters.
- Interactive cockpit and private link, BUILT and proven:
  - serve.py is now an interactive local server (stdlib http.server, ThreadingHTTPServer, 127.0.0.1:8765). GET serves the page and /api/hidden JSON. POST handles /api/hide, /api/unhide, /api/bid, writing to the local database. The page must be opened at the served address (localhost or the private link), never as a file, or the toggles cannot save.
  - New db tables: hidden_cars (reg unique, reason, make, model, name, hidden_at, active, reviewed) and bids (reg+sale_date unique, make, model, name, max_bid, reserve, created_at, active). db functions: hide_car, unhide_car, hidden_regs, hidden_notes, mark_notes_reviewed, set_bid, bids_for. connect now uses timeout=5 for the threaded server.
  - Cockpit page (render.py render_page): each car has a Bid star (Steven's own shortlist, the app never bids) and a Hide, would not buy button with a reason box. A control bar gives search (reg, make, model), platform filter, sort (cheapest reserve, biggest headroom, platform), and Show only my bids. A seen before badge shows on repeat cars. JS uses fetch() to the api. Hidden cars page is hidden.html (render_hidden), live from /api/hidden, with unhide.
  - daily_run: run() and render_only() both suppress hidden regs (db.hidden_regs) and pre tick the bid stars (db.bids_for) via the shared _write_pages, which writes cockpit.html and hidden.html. New command python3 daily_run.py --hidden-notes lists new hide reasons for Claude to mine into rules in pricing.py, then db.mark_notes_reviewed.
  - Private web address: Cloudflare quick tunnel. cloudflared installed at ~/.local/bin/cloudflared (downloaded binary, no Homebrew). Start with: python3 serve.py (background), then ~/.local/bin/cloudflared tunnel --url http://localhost:8765. It prints an unguessable https://<random>.trycloudflare.com link that proxies GET and POST to the local server, so the toggles work from a phone. The quick tunnel link changes on restart and has no password by design. Swap to a stable named tunnel later. Proven: bid and hide POSTs persist to the database both on localhost and through the tunnel link.
  - Rule learning loop: hiding a car suppresses that exact reg automatically. Recurring reasons become hard rules by Claude reviewing --hidden-notes and editing the rule lists, the same way this session turned Steven's pasted reasons into rules.
  - Cockpit controls redesign (Steven's feedback): the bid control is a star button on the photo, top right, sitting with the grade circle and platform badge (gold when on). Hide is a quiet "Hide, not for me" link under the bid button that opens a small reason panel. The old full width buttons were removed. starbtn and hide-row in render.py.
  - One hide control everywhere (Steven found two confusing): there is now a SINGLE hide mechanism used on every page. A quiet "Would not buy this" link opens a reason field, and one "Hide it" button saves the reg and reason to the hidden_cars table via POST /api/hide. It lives in render.py as the shared HIDE_BLOCK (markup) and HIDE_JS (behaviour) constants, reused by both the valued cockpit card (_shortlist_card) and the pre valuation review card (_review_card). The trigger says "Would not buy this" and only the commit button says "Hide it", so there are not two competing hide actions. The old review page checkbox plus copy and paste textarea flow (toggleCar, localStorage, the paste box) is GONE, replaced by this same DB saving flow. Do not reintroduce a second hide mechanism. Note cockpit.html is written by two paths: render_page (the real valued run) and render_review (daily_run.py review, the pre valuation cull); both now share the one hide control.
  - Automatic daily schedule, BUILT via launchd. LaunchAgents in ~/Library/LaunchAgents: com.bidbrain.daily runs python3 daily_run.py at 17:10 every day (after 5pm so both platforms show next day stock) and com.bidbrain.serve keeps serve.py up (KeepAlive). Logs in data/logs. Load or reload with launchctl load -w. This is why the project had to move off the Desktop. The old com.bidbrain.tunnel agent (cloudflared quick tunnel) is RETIRED, its plist archived as com.bidbrain.tunnel.plist.disabled, replaced by Tailscale Funnel below.
  - Browser leak fixed, was causing hung runs (2026-06-09). browser.open_reader_context, when a saved session state exists (Glass's, Cazana, Carwow), did playwright.chromium.launch then b.new_context, but callers only ever call ctx.close(), which closes the context and leaks the whole Chrome. Over a full run that is one leaked browser per valuation; a manual run piled up to 379 chrome-headless-shell processes and hung (main thread stuck in select, 1.8s CPU in 51 min). Fix: open_reader_context now wraps ctx.close so it also closes the launched browser (monkey patched the context's close to call the original then b.close). Proven: 5 open/close cycles stayed flat and returned to 0 chrome processes. The persistent profile branch (Motorway) was never affected since ctx.close on a persistent context closes its browser anyway.
  - Run progress bar, DONE (2026-06-09). daily_run writes data/run_progress.json via _progress(phase, message, done, total) at each step (phase is reading, valuing, writing, done, error; ts is a wall clock time.time()). __main__ wraps run() so a crash writes phase error then re raises. serve.py /api/run-status now also returns the progress object plus the server clock now. The cockpit shows a thin progress bar as a full width row inside the sticky control bar (render.py .runbar/.runtrack/.runfill, renderProgress in JS): green fill with done/total while valuing, an indeterminate sweep while reading (total 0), amber plus "no update for Ns" if the server clock shows the progress ts is over 90s stale (so a future hang is visible not a silent spinner), red with the message on phase error (polling stops, message stays up). The page polls run-status every 3s while a run is active and reloads when phase is done. Works for the 5.10pm scheduled run too since it reads the same file, not just manual runs.
  - Manual Run now button, DONE (2026-06-09). The cockpit control bar has a green "Run now" button (render.py, .runbtn). It POSTs to serve.py /api/run, which starts a full daily run as a detached background subprocess (sys.executable daily_run.py, cwd project root, output to data/logs/manual_run.log), the same as the 17:10 scheduled job. serve.py guards against overlapping manual runs with an in memory _run_lock and _run_proc, and exposes GET /api/run-status ({running, last_run mtime of data/last_run.json}). The page confirms before starting, then polls run-status every 8s and reloads when the run finishes. Caveat: the in memory guard only blocks manual vs manual overlap, it does not know about the 17:10 launchd run, so avoid pressing Run now right at 17:10. Also serve.py now sends no-cache headers (Cache-Control no-store, Pragma, Expires) on every response via an end_headers override, so phones always get the fresh page after a run or a design change.
  - PERMANENT private web address, DONE (2026-06-09), replaces the Cloudflare quick tunnel. The link never changes: https://mac-studio.tailc25a81.ts.net/cockpit.html . Set up with Tailscale Funnel (free, no domain, no password by design, unguessable but public, which matches the old behaviour). Tailscale app installed on the Mac Studio, signed in as stevendouglas@, machine name mac-studio, tailnet tailc25a81.ts.net. Funnel enabled once via the account link, then turned on with: /Applications/Tailscale.app/Contents/MacOS/Tailscale funnel --bg 8765 (proxies https root to the local serve.py on 127.0.0.1:8765, GET and POST both work so bid and hide save from a phone). The --bg config persists in the Tailscale daemon across reboots, so no launchd agent is needed for it; the Tailscale app must stay running and signed in. Check it with: /Applications/Tailscale.app/Contents/MacOS/Tailscale funnel status . Turn off with: tailscale funnel --https=443 off . cloudflared is no longer used (binary still at ~/.local/bin/cloudflared if ever needed). Proven: the link returned HTTP 200 and served the cockpit on first hit.
- Decisions log:  
  - Buying brain signed off by Steven. See above.  
  - Bids are always placed by Steven, never by the app.  
  - No platform APIs. Everything via logged in screens and provided reports.  
  - Data source is Clickdealer now, moving to DealerKit later.  
  - Phase 1 stack chosen: Python 3 standard library plus SQLite. See the architecture section for file paths.
  - Gate edge cases set to exclusive limits: mileage and reserve must be strictly under their limits, distance must be 210 miles or under, age 2 to 10 years inclusive. Confirm these match Steven's intent.
  - Final build shows only the shortlist. The held back and rejected cars are for Phase 1 checking only and are hidden in the real daily page. Controlled by SHOW_EXCLUDED in build.py, on now for checking, off for the final build. Excluded cars are still saved to the database either way.
  - House style: the app's own copy is hyphen free. Manufacturer trim and engine names entered in the data (such as Ti-VCT) keep their real spelling. Decided by Steven, the hyphen rule applies to the app's prose, not to factual names read from a listing.
- Open items to confirm by inspecting live pages: exact Motorway and Carwow listing fields, how Glass's and Cazana show the retail value, whether the Clickdealer SIV page has a download, and how to turn a listing location into miles from NE3 5HE. Steven to confirm Glass's and Cazana are content with reads through his own login.

