Skip to content

RETIRED — this is the v1 runbook (through 2026-08), kept verbatim for rationale and incident history. Current rules live in CLAUDE.md + the gates + the agent contracts.

mtn_research — build runbook for Claude

This repo publishes deeply-researched Colorado 13er/14er reports to a MkDocs site on Cloudflare Pages (mtn-research.pages.dev; GitHub Pages is retired and dark, and the repo is private with the GPX source tracks committed — the clone lives at ~/dev/mtn_research, never iCloud). The mechanical pipeline is a set of allow-listed scripts/*.py. Your job is to drive those rails and write the prose — not to re-invent the pipeline with ad-hoc shell. (A 2026-06 "simple report" ballooned into dozens of permission prompts because I improvised with inline python3 heredocs, /tmp scripts, and raw grep/sed instead of using the scripts. Don't repeat that.)

Operating mode (Kyle, 2026-06)

  • Autonomous: build → run all gates → commit → push → report. Don't stop to ask "should I push?" — publishing to his own Pages site is pre-authorized.
  • Stop ONLY for: a gate failure, or a genuine judgment call (ambiguous/conflicting scope, something destructive/irreversible, or a fork that's expensive if wrong).
  • Don't prompt Kyle for auth pre-emptively. Only ask him to log in if the MCP browser itself shows logged-out (or a sweep returns a login page). Never prompt off check_sources_login.py's standalone profile — it diverges and false-flagged a login.
  • Retro after every report — measured in Kyle's interaction (Kyle, 2026-06-16 / reinforced 2026-07-12). End EVERY build with an explicit evaluation of how much interaction this report cost Kyle and how to drive it toward zero next time. Count the concrete touches: permission prompts, manual clicks (e.g. peakbagger Cloudflare), corrections Kyle had to make, decisions I kicked to him that a sensible default could have settled, and anything I hand-wrote in shell that should be a script. Research-site logins do NOT count (Kyle, 2026-08-07: "that's normal") — session expiry is ops, not process failure; still record them on the run's status page timeline. For each, standardize + allow-list the fix immediately — a new/extended scripts/*.py (with its .claude/settings.json allow entry), a gate, a default recorded in CLAUDE.md, or a memory. The bar: the same interaction should never be needed on the next report. This is not a nicety — it's the point. (See the ≤3-prompts-per-run budget below and [[feedback-fewer-shell-prompts]].)
  • Assume, don't ask: individual-vs-combo and trip grouping — pick the sensible default; Kyle redoes if he wants it the other way. When ≥2 reports share a drainage/area/drive, proactively build the Trip.
  • Grouping is decided by shared APPROACH INFRASTRUCTURE, never summit-to-summit distance (Kyle, 2026-08-12): "its not always relevant how far summits are from each other. what matters more is are the approaches doable from common areas. do trailheads combine, or do parts of the route come together. if not then they should be separate." Before surfacing any combo, check the neighbors' EXISTING reports' researched trailheads first — that data settled pt_13060_b as solo without a map: its tempting neighbors Daly A (Capitol Creek TH) and Siberia Pk (Geneva Lake TH / Lead King 4WD, other side of the divide) share nothing with Snowmass Creek. A neighbor table sorted by summit distance is the wrong lens and asks Kyle a question the repo already answers.
  • Far-from-home + same-area ⇒ SURFACE the combined trip, even if Kyle asked for individuals (Kyle, 2026-07-12). Two 11-hour round trips to the same area is the thing to prevent. Trigger: peaks share an access area/road system AND the drive from home is long (drive_h ≥ ~4). When that's true, don't silently build separate reports — push back / offer the combined multi-day (car-camp or backpack) option up front: "these are both ~5.5 h to Gold Basin — worth one trip; want the combined report?" He can still say individual (he did for Fairview/Henry — but I should have flagged the Gold Basin trip). A cross-link in each report's Other considerations is NOT enough; say it in chat when you notice, before building. (Miss: built Fairview + Henry solo without offering the ~5.5 h Gold Basin combined trip.)
  • Default to single-push combos; don't over-hedge. Kyle's actual log (601 ascents) shows a high multi-peak-day tolerance — 4+ peaks on ~24% of his days, up to 12 in a day; 3–6 peaks in one push is routine. So when clustered peaks can be linked, combine them and frame the big day as achievable — don't lead with "backpack instead" or call a 3-peak / ~20-mi day a "monster." Offer the backpack as an option, not the headline. (See [[feedback-climbing-patterns]].)
  • Prefer LOOPS; treat recommended mileage as a floor. Calibrated against Kyle's actual tracks: he always loops (never out-and-backs) and does ~15–25% more distance than my route (much more on single-peak loops); gain estimates are reliable. So recommend loop options (esp. single peaks), and don't call these outings "runs" — he fast-hikes up and jogs down. (See [[feedback-climbing-patterns]].)
  • Class 3 ridge beats a longer bypass — standing default (Kyle, 2026-08-07, gold_dust: "in general the class 3 ridge should always be preferred over longer distance and more gain"). When a direct connection is Class ≤3 and a longer/more-gain bypass exists, recommend the direct line WITHOUT asking — the composed-difficulty rule still requires the report to name the easier alternative with its cost, but it's a note, not a question. Still stop (with a map) for Class 4+, serious exposure, or an unproven direction on hard terrain. This aligns with the composer's cost function, so the default output is usually already right. (See [[feedback-prefer-class3-ridge-over-distance]].)
  • Never invent a straight-line combo connector. If no recorded track links two peaks, make them individual climbs (each its own <trkseg>). The teleport gate enforces this.

A parked report NEVER publishes non-compliant (Kyle, 2026-08-15)

"I never want to publish anything that needs my opinion or is failing any gates. What I'm saying is the thing that needed input should revert to whatever is currently published and that should be no less compliant than current." A parked run's work is salvaged ONLY when the report is complete (prose written, route built) AND passes EVERY per-slug gate on its own — then it publishes and the question goes to the answer queue. Anything else restores the live report byte-identically, so it stays exactly as compliant as it is today. There is no tier of "forgivable" gates and no per-report waiver that lets a red gate ship: I built exactly that first (an open_question.yml waiver in run_gates plus a safety tier) and it was wrong — publishing a failing report is the thing he was ruling out, not the thing he was asking for. The value this recovers is the common case where the agent's question broke nothing: a route variant worth offering, a preference, a second option. See park_publishable().

Stopping a batch for tooling: BANK, CLEAN, FIX, PUSH, THEN restart (Kyle, 2026-08-14)

Never fix tooling with a batch's work sitting uncommitted. When a batch stops and the answer is a tooling change, the order is fixed and each step completes before the next: 1. STOP the batch — no half-running queue while the tooling under it changes. 2. PUSH the research that is BANKED — swept tracks, cached beta, published reports. Harvested data is never thrown away for being inconvenient to commit. 3. CLEAN the state — abort the runs that are not finishing, and THROW OUT their residue (partial peaks.yml edits, auto-migrated deviations.yml, reconstructed inputs). Residue from an aborted run belongs with the report it came from, gated alongside it — never smuggled into a tooling push. 4. DO the tooling work, then PUSH it — small, self-consistent increments, each passing the gates on its own. A branch must not reference scripts it does not carry. 5. VERIFY a clean state — clean tree, nothing unpushed, gates green — before starting any new batch. Why (Kyle, 2026-08-14: "this has just purely been bad development technique and i want to make sure we don't get here again"): on 2026-08-13 a night's tooling changes, a night's sweep data and the residue of eleven aborted runs were all committed together, and the push then failed repeatedly against gates that were correctly objecting to the residue. It cost the overnight run and hours of untangling — and the untangling itself kept tempting a --no-verify that would have defeated the gates entirely. Small increments never reach that position.

The rails — how to build a report (do NOT improvise around these)

THE ALLOW-LIST IS FLIPPED (Kyle, 2026-08-07): the guarded builders — scaffold_report, build_route, compose_best_route, build_trip_day_routes, build_report_steps — are NOT allow-listed. Only build_report.py (the orchestrator) drives them, with its run token. A direct invocation both refuses (orchestrator_guard) AND prompts Kyle — a prompt from one of these is a bug report, not a request. Exception: build_recommended_route.py stays listed for its guard-permitted SCRATCH mode only (--out outside the repo + --no-export — decision previews for grouping maps); real builds from it still refuse. Every step below is an allow-listed call or a Grep/Read/Edit/Write tool. A single-peak/group report is essentially:

  1. scripts/preflight.py --slug <slug> --ids <peak_db ids> — resolve peaks, creds, climber profile → GO/NO-GO. Surfaces ambiguity NOW, not mid-build.
  2. Sweep GPX from all 3 sources in the Playwright-MCP browser (already logged in — it's the login source of truth, and it clears peakbagger's Cloudflare check). Save tracks into gpx/<slug>/. This is THE sweep path — use it for all three. DELEGATE the sweep to the sweeper subagent (.claude/agents/sweeper.md, added 2026-08-06) — it drives the same MCP browser on the same rails and reports counts; the main session's scope is research judgment, not the mechanical harvest. It stops and reports if a source is logged out (then ask Kyle). The gates already verify its output (fetch-record freshness, login probes refusing false-empties, source-coverage). RESEARCH + PROSE are the REPORT AUTHOR subagent (.claude/agents/report_author.md, added 2026-08-07) — ONE agent per slug, spawned at the research stop and RESUMED at the prose stop so the context that read the beta writes the narrative. The main session's job narrows to: pre-research narrow-down with Kyle, spawning/resuming the agents, reviewing the peaks.yml and prose diffs before each --advance, and relaying Kyle's calls (markers/groupings/direction — the agent must STOP on those, never decide them). BATCH PASSES PRE-SWEEP FIRST (Kyle, 2026-08-08: "you pre sweep all the sources for each report in the queue all together so the one that happens as a part of each report build is just a no op… we need to figure how to do a lot of these without my interaction"). Before a multi-report pass, spawn the presweeper subagent (.claude/agents/presweeper.md) with the whole slug list: it probes all three logins ONCE, then sweeps every slug back-to-back in one browser session. The orchestrator accepts a sweep from the last 12 h (PRESWEEP_HOURS), so each build's sweep phase becomes verify-only — the finicky-login risk is concentrated in one window instead of one chance to park per report. The sweep is DELTA by construction (Kyle, 2026-08-07): swept tracks + .sweep_state.json survive the rebuild quarantine as source data; every sweep still enumerates all three sources fresh (new uploads always caught, freshness stamped), but content the manifest already has is never re-downloaded (14ers by URL, LoJ by gpx id, pb by ascent id incl. checked-and-empty ascents). No flags — a fresh slug is automatically a full sweep.
  3. ALWAYS scrape EVERY objective peak's GPX library, then DEDUP the combined set (Kyle, 2026-06-16). A single peak's library misses combo/loop tracks that start from a different peak — the Wayah Group "all-6 loop" tracks lived on the other peaks' libraries, so a one-peak sweep made me wrongly call it a point-to-point shuttle. The same track also appears in several peaks' libraries, so after scraping all of them, dedup (by file/track) before building the collection. This is non-negotiable — never sweep just one or two of the objective peaks.
  4. LoJ GPX is on TRIP REPORTS, not peak pages (Kyle, 2026-06-17). A LoJ peak page (/peak/<pkid>) has NO .gpx link — checking it says "empty" and is a FALSE negative (it falsely flagged Gladstone, which has 4 TR tracks). The real path: peak page → trip-report ids (tr?Id=<id>&pkid=<pkid>) → each TR page exposes a gpx id → download listsofjohn.com/gpx/<gpxid>.gpx. sweep_peak.py --emit loj now does this harvest. LoJ tracks are ~60% UNIQUE vs 14ers/peakbagger (measured, source_overlap.py) — worth harvesting, not duplicates.
  5. Never prompt Kyle to log in based on check_sources_login.py — it checks a separate standalone profile that routinely diverges. Verify in the MCP browser, or just sweep and let a failed fetch (login page, not GPX) be the signal.
  6. Do NOT send Kyle to check_sources_login.py --login / sweep_gpx.py for peakbagger. That standalone profile gets stuck forever on peakbagger's Cloudflare "Verifying you are human…" wall (it's an automated profile Cloudflare won't clear — we hit this 2026-06-16). The MCP browser is the only reliable peakbagger path; sweep_gpx.py is at best a 14ers+LoJ headless convenience.
  7. peakbagger Cloudflare — clear it WITHOUT bugging Kyle first (2026-07-12). In the MCP browser, pb usually clears on its own; the "Just a moment…" challenge is a JS check that auto-redirects in a genuine browser given a few seconds. If a pb navigate returns "Just a moment…"/403, wait ~8–10 s (browser_wait_for) and re-check ONCE — do NOT hammer reloads (back-to-back reloads interrupt the JS challenge; that's what forced a manual click on the La Plata build). Only if it's still walled after the wait, ask Kyle to click the checkbox — that's the one legitimate human touch, and it should be rare.
  8. File the swept tracks into gpx/<slug>/ with scripts/ingest_gpx.py --slug <slug> --json <blob.json> (for browser_evaluate blobs; direct downloads just get named + moved there). 3b. Sweep the full OSM trail network with scripts/sweep_osm_trails.py --slug <slug> — ALWAYS run this alongside the 3-source recorded-track sweep (Kyle, 2026-07-22). Writes trail_osm_<name>_<way-id>.gpx per mapped trail in the objective+context+landmark bbox (default 2-mi pad). OSM trails go into route composition as ordinary source tracks — the graph router picks either recorded tracks or mapped trails as walkable edges. The Rio Grande Pyramid + Rito Alto builds both burned iterations discovering trails that OSM already knew about; the sweep prevents this. Files skip source-coverage (OSM ≠ recorded beta). See [[feedback-osm-trail-sweep-standard]]. 3c. Research the TRAILHEAD with scripts/find_osm_trailheads.py (added 2026-07-27) — --slug <slug> clusters the recorded track STARTS and queries OSM around each cluster for highway=trailhead nodes, parking areas, TH kiosks and the access road (with surface / access=private / 4wd tags); --near lat,lon checks one point. This is how the HARD RULE ("a TH is a researched fact — never an OSM road endpoint") gets satisfied, and it is what caught Pk L + Pk N's start having no trailhead or parking within 2 mi, past a smoothness=very_bad FS road, near access=private roads. Combine its output with the beta (climb13ers naming the TH) and where the tracks actually start — all three must agree. 3d. Pick the route recipe from EVIDENCE in one pass — never by sweeping parameters. Read: track_seasons.py <slug> (reject winter/ski lines), list_source_tracks.py <slug> --starts --which --order--order prints the summit VISIT ORDER along each track so a multi-peak report describes the day in the sequence parties actually walked it, and --starts now flags ⚠ STARTS <n> ft ABOVE the TH (a private/4WD spur that shaves gain). Then let check_trip_continuity / check_route_fidelity confirm. If a gate fails, fix the CAUSE; a bounded, reasoned accept_route.py entry (or per-report fidelity_max_ft) is legitimate, sweeping routing knobs until a gate goes green is not.
  9. scripts/scaffold_report.py --slug <slug> --objective-ids … writes gpx/<slug>/peaks.yml; then fill in nearby.include, the trailhead landmark, and the route_build: recipe (see step 6).
  10. scripts/build_report.py --slug <slug> — chains the whole data phase (build_peak_gpx → caltopo_mytracks → combo_stats → drive_time → build_route from the peaks.yml recipe → make_overview_map → gpx_to_caltopo --new-map → summit markers → sync_to_regional). If peaks.yml has no route_build:/days: yet it warns and skips the route — add the recipe and re-run (or run build_route.py + make_overview_map.py yourself) before --finalize.
  11. The route RECIPE (route_build: in peaks.yml) — scripts/build_route.py <slug> re-runs it standalone when iterating. It dispatches to the right builder; gain from DEM, distance from GPX. The recipe records HOW the route is built so it's reproducible (routes are gitignored — a plain build_recommended_route.py rebuild can silently replace a good route with a wrong one; cuba did exactly that). Recipe forms: {method: composed_segments, segments:[…]}the best line per LEG, stitched across different recordings (built by compose_best_route.py <slug> --why, which prints every candidate per leg with its cost so you can see what lost; --apply writes the recipe). Run the composer on EVERY new multi-peak report before settling on a recipe — a single recording is one party's whole day, and the best ascent, best inter-peak traverse and best descent are usually in different files. Already in production on carter_dome_group, imogene_pass_group, lake_fork_peak, pt_13100_13089, pt_13180_a. It was MISSING from this list until 2026-07-30, which is exactly how boskoff_group shipped a 15.07 mi from_track line where the composer found 8.93 mi from four recordings — Kyle: "we've done a lot of work recently to teach the scripts how to prefer one track for part of the climb and stitch in another part… why the regression?" Sweep access first (sweep_osm_access.py --slug <slug>) — the composer refuses to vouch for a proposal on unswept land. {method: from_track, track: "<substr>"} (one recorded track verbatim — follows every switchback, and the right answer only when ONE recording genuinely is the best whole day), {method: graph} (shortest-path; RDP-simplified, can cut corners — the fidelity gate flags those), {method: legs} (per-leg/whole-track stitch), {method: multi_segment, tracks:[a,b]} (disconnected objectives → separate <trk>s via build_multi_segment_route.py; never invent a straight connector), {method: frozen} (route can't be regenerated — the committed *_recommended.gpx IS the source, allow-listed in .gitignore). For a new report, pick a track-following recipe; if unsure run scripts/infer_route_recipe.py <slug> to find what reproduces a built route. A trip (days: block) builds per-day via build_trip_day_routes.py, and each days: entry may carry its own route_build: (composed_segments / from_track) — a trip has no top-level recipe, so per-day is where composition lives; check_route_composition.py requires every multi-peak day to show it was considered. The iCloud Documents/GPS Tracks/ mirror is OPT-IN as of 2026-07-30 (Kyle: "please don't use that directory. only the repo or your local directories… it should be temporary anyway because things should live in the repo") — no build writes there any more; run export_to_gps_tracks.py <slug> (or build_recommended_route.py … --export-gps-tracks) when a phone copy is actually wanted. check_route_recipe.py (a gate) FAILs unless the recipe reproduces the committed route.
  12. Write docs/peaks/<slug>.md (prose + structured frontmatter) and add it to mkdocs.yml nav (check_nav.py, a gate, FAILs any report unreachable from its site's nav).
  13. scripts/build_report.py --slug <slug> --finalize — climber-status + index + quickstats + peak-map + all gates (nav, teleport/geometry, route-stats, maps, extents, reports).
  14. git add … && git commit … && git push (all allow-listed).

Hard rules

  • Fix the LOGIC, not the artifact — then rerun it, and add a test that would have caught it (Kyle, 2026-07-24). HARD RULE, governs everything below. On ANY feedback or defect, the job is three steps, never one: (1) fix the generator — the scripts/*.py, the peaks.yml recipe/config, the placement algorithm — that PRODUCED the artifact, not the output file; (2) rerun the logic so the artifact comes from the fix (and is reproducible — the recipe gates verify this); (3) build a gate/test that FAILs on this class of problem, wire it into run_gates, and run --all to catch every existing instance, not just the reported one. Never one-off hand-patch a map/report/route — it's right once and regenerates wrong next build; the class of bug must die. "Getting to the right report is only part of the objective." (See [[feedback-fix-the-logic-not-the-artifact]], [[feedback-no-hand-patching]].)
  • Trailheads + routes are per-report RESEARCH, never fleet-mechanized (Kyle, 2026-07-24). A trailhead is a researched fact — confirm it from beta (14ers TH page / trip reports on the current road closure) + where recorded tracks actually START, and from an OSM highway=trailhead node — never from an OSM road/way endpoint (an OSM "road" can be gated/closed far below its drawn end; South Colony's real TH is down the road from the line's end). No fleet-wide mechanical route re-optimization ("scan all + rebuild optimal" is out-of-context one-off work); route changes go through the full report workflow so context is loaded and gates run. The route audits (audit_recommended_routes / audit_route_excursions) are read-only FLAGS, not auto-fixers. Fold swept OSM trails into a report's ROUTE only on a deliberate from-scratch refresh — keep them as available source data otherwise (inert for from_track/legs). (See [[feedback-routes-and-trailheads-need-report-context]].)
  • One recommended route per DAY for multi-day trips — and per LEG for backpacks (Kyle, 2026-06-21 / 2026-07-10). A single-day report has exactly one recommended route; a multi-day Trip (frontmatter days: N, N>1) has N routes — one composed line per day (the day clusters can be miles apart with no recorded track between them, so there is no single line). Build them with scripts/build_trip_day_routes.py <slug>, which reads a days: block in gpx/<slug>/peaks.yml (each day = {label, objective_ids} — a subset of the trip's objective_ids), composes each day's route from its objective subset + nearest trailhead (via build_recommended_route.py --peaks-only), and writes day_<label>_recommended.gpx. Backpack trips additionally draw EVERY non-climbing leg (pack-in, camp moves, pack-out; one corridor line when pack-in/out coincide): peaks.yml legs: entries ({label, track} verbatim recording, or {label, target: <camp-wpt gpx>, start: "lat,lon"} composed point-to-point) → leg_*_recommended.gpx, and backpack climbing days use the days: entries' optional track: (verbatim FROM-CAMP recording — TH-composing a from-camp day redraws the approach and misplaces the day). Verify leg endpoints CONNECT (TH→camp→camp→TH); GPS recordings often start late — compose those legs instead. gen_peak_map/make_overview_map draw EVERY *recommended*.gpx, so all day routes show on the home + overview maps. No no_single_route exemption — it's gone (it left South San Juans with no route at all); check_route_exists.py now FAILs a trip with fewer routes than days. (Don't leave a stale combined <slug>_recommended.gpx next to the day files — delete it so it isn't double-drawn.)
  • Rebuild a research map → leave NO duplicate/orphaned CalTopo map (Kyle, 2026-06-21). Each build_report rebuild WITHOUT --caltopo-id mints a NEW CalTopo map and repoints the report's frontmatter to it — orphaning the OLD map on the account. A lingering orphan is the "wrong version" Kyle opened by mistake. This is now closed on both ends: (a) build_report.py's --new-map branch reads the report's current caltopo_id and deletes that superseded map after the new one is created (targeted — only the id this report pointed at; via delete_caltopo_map.py … --force); (b) --finalize runs scripts/audit_caltopo_maps.py (non-fatal) to surface any other orphaned "Research:" map. After any research change, the account is clean only when audit_caltopo_maps.py reports 0 orphaned; prune leftovers with audit_caltopo_maps.py --prune (or delete_caltopo_map.py <id> --yes). Personal maps ("GPS Tracks — …", named hikes) are never touched. CalTopo is local-only (cts.ini gitignored) — these checks no-op in CI.
  • Kyle's recorded climbs sync onto the research maps (Kyle, 2026-06-22). peak_checklist drops Garmin climbs as gpx/<slug>/_kyle_existing/<peaks> YYYY-MM-DD_actual.gpx; scripts/sync_kyle_recordings.py puts them on the slug's CalTopo map + PNG (ledger-gated no-op, --dry-run, soft-fail, auto commit+push). Map resolution is duplicate-safe: peaks.yml caltopo_map_id → else report frontmatter caltopo_id (backfilled into peaks.yml) → else create new — so it reuses the existing research map instead of orphaning a duplicate. Conventions: Kyle's recordings render blue #0066FF (gpx_to_caltopo forces KYLE_COLOR; recolor_kyle_tracks.py --all --apply fixes old ones); objective summits green #39FF14 peak, ALL objectives (not climbed-only); other named/ranked summits in the PNG frame = black #000000 peak context markers, on CalTopo AND the PNG (Kyle, 2026-07-12 — one green/black mountain-icon convention everywhere; fix_summit_markers.py --apply reconciles both marker sets on any map, reading the context list from the PNG's .extent.json sidecar); recommended routes magenta. restyle_markers.py is regional-only. Detail: docs/methodology/caltopo-pipeline.md.
  • A research map carries FOUR folders and exactly ONE current route (Kyle, 2026-08-15). Folders are OSM trails / Beta tracks / Recommended route / Markers — fixed, never one per file (folders keyed off filenames gave homestake_peak 366 of them and the map stopped opening). Swept OSM trails DO belong on the map — "its nice to have them in case I need to bail a different direction… I know what established trails are close" — filtered to --osm-near-mi (default 4) of the recommended route, with residential ways kept for the composer but off the map. And a superseded recommended route is deleted, never left beside the new one: "why would I want an old recommended route? Seems like there should only be the latest." build_report_steps runs prune_caltopo_tracks.py --slug <slug> --tidy --apply after every push, so this is automatic; --inspect shows a map's folders, and plain --apply additionally removes non-summiting tracks (not run inside a build).
  • HIGHER POINT WINS — STANDING RULE, DO NOT PROMPT (Kyle, 2026-08-07: "Always use the higher point if it is consistent. don't prompt me again if theres a higher point. we can revisit this once I have some walked tracks with summit markers for you to inspect"). Every marker verdict Kyle ever gave by hand came out the same way: the DEM-higher point is the summit, the track convergence is a dwell artifact (rest cluster, boulder constriction, braided route). resolve_summit_markers.py now applies it automatically at finalize — convergence LOWER ⇒ KEEP the marker; convergence higher by ≥20 ft ⇒ MOVE the marker there; only a DEM tie (<20 ft) still escalates. Both outcomes are written to deviations.yml under his standing-rule quote, so they survive rebuilds. The crop stays published for after-the-fact review — revisit when Kyle has walked tracks with his own markers.
  • A SUMMIT-MARKER QUESTION IS ALWAYS ANSWERED WITH A MAP — never prose alone (Kyle, 2026-08-02: "for the summit marker question show me in a map. (always)"). Whenever check_summit_markers / summit_from_tracks flags a marker and you are deciding whether to MOVE it (summit_overrides) or KEEP it (accept_summit_marker.py), render the crop and send it before/with the decision. Coordinates and foot-offsets do not let Kyle judge it; the picture does in one look. The rail is two calls — always --slug the report you are building; without it the sheet runs the whole fleet AND cannot see a report whose .md doesn't exist yet (slug discovery reads docs/peaks/*.md), so a mid-build peak silently drops out and the empty sheet reads as "marker fine". --slug keys off gpx/<slug>/peaks.yml instead, and exits loudly on an unknown slug: scripts/summit_verify_sheet.py --slug --out /verify.json scripts/summit_review_crops.py --sheet /verify.json --out /review.html \ --all --only "KEEP?" --png-dir /crops (summit_from_tracks flags at 60 ft but the gate fails at 150, so a marker can be flagged MANUAL and still pass; add --fail-ft below the gate's to sheet those.) then send the per-peak PNG. The crop shows the marker (red crosshair), the track convergence (green dot) and every recorded point (blue), over contours — so "the convergence is downhill on the trail" is visible rather than asserted. Treasury Mtn is the worked example: three databases agreed on the marker to five decimals, the 9-track convergence sat 396 ft away and 72 ft lower on the trail, and the crop made that obvious instantly.
  • A question reaches Kyle as an ARTIFACT PAGE, not a chat attachment (Kyle, 2026-08-13): a PNG attached in chat renders as a dead placeholder on his phone when he is on remote-control, and CalTopo links alone can't show a superseded line. Build the page, embed the map(s) with scripts/inline_png.py (the artifact CSP blocks external images), and send ONE url — it works off-network. Map rules, learned on fairview_peak: one overlay map by default (two lines, one basemap); split into vertically STACKED panels only when one route is nearly a subset of the other and the overlay would hide it — never side-by-side, which is unreadable on a phone; and check the frame contains both routes end to endroute_diff_map.py cropped part of the line. See [[feedback-decision-map-layout]].
  • EVERY route-flavored question to Kyle is asked WITH a rendered map — ALWAYS, never offered as an option (Kyle, 2026-08-12: "just always make a map if its questions about routes. I still can't get you to remember that" — his THIRD correction of this class). This generalizes the two rules below: grouping, trip-vs-solo structure, TH choice, direction, marker placement, route variants — render the evidence (grouping_map / summit_review_crops / route_diff_map, composed objectives-only lines via the composer's scratch mode) and send the PNG in the same message as the question. Prose-first with a map "if he wants it" is the failure mode, not a courtesy.
  • A PROPOSED GROUPING IS ALWAYS SHOWN ON A MAP — markers AND routes (Kyle, 2026-08-02: "always show me groupings on a map" / "not just the markers, show me the routes"). Whenever you propose which peaks go together (which days, which trailheads, individual-vs-combo), render it before writing the prose. A table of peak names cannot be judged: whether the peaks lie on one continuous ridge, whether two "days" actually overlap, and whether a trailhead is on the right side of the divide are all obvious in one look and nearly invisible in text. Markers alone are not enough — and the routes drawn must be COMPOSED, objectives-only lines, never whole recordings (Kyle, 2026-08-07, gold_dust; same lesson first learned on wwlg): a recorded day carries that party's extra summits and detours, so its distance/gain mislead the decision ("these routes don't seem to be the most effective way… has an extra summit"). Scratch-compose each candidate day first — build_recommended_route.py <slug> --peaks-only <day-subset.gpx> --start <TH> --transfer-eps 35 --out <outside repo> --no-export — and label each group with the composed mi + DEM ft. Whole recordings are evidence a linkup GOES, cited in text, not drawn as the proposal. The rail is scripts/grouping_map.py (works from peak_db ids BEFORE any report exists): scripts/list_source_tracks.py --which --order # pick the track per group scripts/grouping_map.py --out /groups.png --title "…" \ --group "Day A — , :" \ --track "Day A — , :" \ --th ":," Get the per-group tracks by scaffolding a scratch slug over ALL the candidate peaks, sweeping, then reading list_source_tracks.py --which --order — the track that covers exactly a proposed group IS the evidence that the grouping is real. An unresolvable peak id is fatal by design: a peak silently missing from a grouping map reads as a deliberate exclusion.
  • TEST-DRIVEN: write the GATE FIRST, then the fix — and wiring gates is ALWAYS a priority (Kyle, 2026-07-27: "Always make that a priority!!! Ideally we do test driven development here"). When a defect surfaces, the first artifact is the check that fails on it, not the fix. Then make it pass. Never the reverse ("fix it now, add a gate later" = the gate never gets added, and the class of bug returns). A new check is wired and blocking the moment it lands — see the source-of-truth rule below for how old reports are handled. Corollary: a check is only worth writing if it can actually MEASURE the thing at the tolerance being asserted. check_summit_markers grid-binned at 25 m (82 ft) while claiming to judge a ~15 ft standard — that isn't a red test, it's an invalid one, and it manufactured a 55-report "backlog" that did not exist. Fix the measurement, then assert the standard.
  • THE GATES ARE THE SOURCE OF TRUTH FOR DRIFT — never a memory, never a KNOWN_* constant (Kyle, 2026-07-27). "I would rather not have deviations in your memory only. I would like the tests to be the source of truth on what inconsistencies we have in reports." So, when a fix becomes a check:
  • It lands BLOCKING for the report being built. Never ship a new gate "unwired so it can't block on pre-existing failures", and never add a global exemption set (KNOWN_STRAY/KNOWN_DRIFT) so --all stays artificially green. Both hide the backlog.
  • Older reports that fail it are RECORDED, not blocking. run_gates.py writes docs/data/gate_status.json and regenerates docs/gate-status.md (the compliance page, in nav). Drift is fine and expected — "drift is okay because we can always stop and rerun the gates against old reports to surface them".
  • The remedy for a row on that page is a FROM-SCRATCH ORCHESTRATED REBUILDscripts/build_report.py <slug> --start, one report at a time (Kyle, 2026-08-04; renamed from rebuild_report.py 2026-08-06 — ONE tool builds AND rebuilds: an existing report gets quarantined at --start, a fresh slug makes quarantine a no-op and takes --ids; the old inner chain is now build_report_steps.py, guarded and NOT allow-listed). The orchestrator quarantines every old artifact outside the repo, requires a fresh sweep (fetch-record freshness, not byte-diffing), runs every build script itself with a run token the guarded scripts demand (orchestrator_guard.py — build_report/build_route/ compose_best_route/scaffold_report/build_trip_day_routes/build_recommended_route refuse direct invocation), and swaps old→new on the live site in ONE atomic commit. Claude's only inputs are data: the browser sweep, peaks.yml judgment, deviations.yml (Kyle's carried-forward instructions — see deviation_kinds.py; an unknown kind is a hard stop), and the prose. check_orchestrated_build.py (blocking) makes orchestrator provenance a requirement, so the compliance page IS the migration plan. Never hand-patch a report, and never drive the build scripts directly. Proven end-to-end on hunts_peak (2026-08-05).
  • When to re-measure the fleet: only when a gate / format-defining file changes — a 1-report gate run is 2 s, the whole fleet is ~2 min 50 s, so it's far too slow to hang off every build but cheap when the gates themselves move. run_gates.py already detects format-defining changes and escalates; that escalation now records fleet failures and blocks only on the pushed report(s). Force it anytime with run_gates.py --all --status-out.
  • A genuinely non-standard thing in one report is legitimate but must be protected AND documented per-report, in-repoaccept_route.pygpx/<slug>/route_accepted.yml, or route_start_above_th_ok: in peaks.yml. The compliance page displays these, so an intentional exemption is visible rather than silent. Per-report and reasoned: yes. A global list inside a gate: never again.
  • Change the report format → refresh EVERY report in the same change (Kyle, 2026-06-18). Any change to the report "format" — a new/renamed frontmatter field, a new gate or required provenance token, a map style/legend, the report template, quickstats, the index/nav schema — is INCOMPLETE until all existing reports are brought up to the new format and scripts/run_gates.py --all passes clean. Never ship a format change that only the new report satisfies (that's exactly how 14 reports silently lost source-coverage
  • provenance). The pre-push hook only gates --changed, so it will NOT catch this for you — after any format change you MUST run run_gates.py --all yourself and fix every report it flags before committing. If refreshing all of them in one pass is too big, it's still not "done": track the remainder as an explicit backlog ([[project-reverify-reports]]) and keep run_gates.py --all as the definition of done. The lock is wired: run_gates.py (pre-push hook) auto-escalates from --changed to --all whenever the push touches a format-defining file (CLAUDE.md, scripts/check_*, scripts/gen_*, run_gates.py, build_report.py, scaffold_report.py, build_recommended_route.py, make_overview_map.py) — so a format change can't land unless every existing report still passes. (CI can't do this — GPX tracks are gitignored, absent there; the lock is local in the hook, which has the working-tree tracks.)
  • A PERMISSION PROMPT IS NOT A COMMUNICATION CHANNEL — announce the bug, then STOP (Kyle, 2026-07-28: "before prompting me to do one off stuff tell me you ran into a potential bug you need to investigate" / "I'm denying it because I want an explicit stop for why you are doing anything outside the allowlisted scripts"). Kyle denies off-rails calls deliberately, to force the stop. So when something can't be done on the rails: say so in chat as a suspected bug/gap, and wait. Never attempt-it-and-see, never retry with a different flag after a denial, never silently pivot to a third approach — all three read as working around him. Corollary: if a call prompts at all, that is EVIDENCE of being off the rails, even when the script seems allow-listed. Investigate the prompt's cause before anything else — the likeliest cause is the two-settings-file divergence: .claude/settings.json exists BOTH in the iCloud project dir (the live one — the session's project directory) and in the ~/dev/mtn_research clone. New allow entries written only to the clone do nothing; three scripts sat silently unlisted that way. Add entries to the iCloud copy and verify by reading it.
  • ONE CALL, NOT A LOOP OF CALLS (Kyle, 2026-07-29: "That was more than the normal permissions prompts for a report"). The prompts in a clean build come almost entirely from wrapping allow-listed scripts in shell loops, which the allow-checker cannot see through. The two that bit on west_elk_pk, and their rails:
  • checking a report's gates → run_gates.py --slug <slug> runs EVERY per-slug gate in one allow-listed call. Never for g in check_trip_continuity check_route_fidelity …; do.
  • confirming a deploy landed → check_site_deploy.py --base https://mtn-research.pages.dev --strict (--base is REQUIRED; without it the script exits 2 on argparse). Never until curl … ; do sleep 10; done. chmod +x on a newly written script is the one legitimate prompt left, and only when adding a script. If a batch operation has no single-call rail, that is a gap to file (see next rule).
  • A RAIL THAT CANNOT ANSWER SOMETHING IS A BUG OR AN ENHANCEMENT — never a licence to hand-check (Kyle, 2026-07-29). "RUN THE NORMAL WORKFLOW AND ALLOW LISTED SCRIPTS. if they are unsuccessful ever then we have a gap in the capabilities and that should be treated as either an enhancement or a bug." This closes the loophole behind most of RULE_DEVIATIONS.md: "the script didn't give me what I needed, so I looked myself." Wrong twice over — usually a rail DOES cover it and I never checked (route_shape.py answers loop-vs-one-way; drive_matrix.py answers TH-to-TH proximity; drive_time.py --estimate computes the drive; list_source_tracks.py --starts prints every track's start/elevation/coverage; check_summit_markers.py judges marker placement) — and when genuinely nothing covers it, the correct output is a filed gap plus a new allow-listed script, not a shell one-liner. Before improvising ANY check: read the script's --help.
  • Use the allow-listed scripts/*.py + the Grep / Read / Glob / Edit / Write tools. NEVER run inline python3 <<'PY' heredocs, uv run /tmp/*.py, or grep/sed/head/cat/find/awk in Bash — none are allow-listed and each prompts Kyle. If you need a reusable check, commit it as scripts/<name>.py AND add a "Bash(scripts/<name>.py *)" entry to .claude/settings.json permissions.allow — there is no glob rule; every script has its own entry, and a missing one is how 700+ one-off approvals piled up in settings.local.json by 2026-07.
  • Budget: ≤3 permission prompts per report run (Kyle, 2026-07-12). Every rails scripts/*.py is already allow-listed, and rg/grep/sed/tail/cat/curl -s auto-allow — so a clean build should prompt ~never. Prompts come from improvising in shell: awk (can't be allow-listed — arbitrary code), rm, chmod, and compound for/while loops or heredocs (opaque to the allow-checker even when the inner command is allow-listed). Rules: (a) never awk GPXlist_source_tracks.py <slug> --starts prints every track's start lat/lon + elevation + max-gap + objective coverage + TH-distance, which is everything the ad-hoc awk gave; (b) don't rm sweep blobssweep_peak.py --ingest deletes the blob itself; (c) run each allow-listed script as its OWN Bash call, not chained in a for loop (the loop prompts, the individual calls don't); (d) read gate/command output with the Read tool, not grep/tail in Bash. When a new parsing need shows up, add it to a script — don't reach for shell.
  • FIRST Bash call of every session: cd /Users/kyleknutson/dev/mtn_research — bare, on its own, nothing after it (Kyle, 2026-07-29). The shell starts in the iCloud project dir, which has scripts/ but no gpx/, so a relative scripts/foo.py there either crashes on a missing gpx/ or reads the wrong tree. That is why a whole session's commands got written as cd ~/dev/mtn_research && … — and that compound is the prompt, not the script. An absolute path doesn't help either: the allow-list entries are relative (Bash(scripts/foo.py *)), so /Users/.../scripts/foo.py misses them. The Bash tool's working directory PERSISTS between calls, so one bare cd at the start makes every later call a clean, allow-listed, relative invocation. Then, per (c)/(d) above: no && chains, no for loops, no > file 2>&1, no ; echo "exit=$?" (the tool reports the exit code), no pipes into grep/sed to trim output — run the bare command and Read the result. For a commit message, Write a file and git commit -F <path>; never a heredoc.
  • Headline distance from measured GPX; gain from a DEM — never climb13ers prose, never GPS <ele> (it logs 30,000′ on a 13er).
  • The 3-source sweep must PROVE itself — claims aren't checking (Kyle, 2026-06-16). The Gladstone report shipped a footer saying "all sources swept" when only 14ers was pulled; the old check_reports lint only matched the footer text. So: actually sweep 14ers + LoJ + peakbagger in the MCP browser (peakbagger GPX = climber/GPXFile.aspx?aid=<aid>&sep=1 on ascents that have a track; LoJ often has no downloadable GPX — that's fine), name files trk_14ers_* / trk_loj_* / trk_pb_*, and when a source truly has none, record it in gpx/<slug>/sources.json ({"listsofjohn":{"checked":true,"found":0,"note":"…"}}). check_source_coverage.py (in --finalize, scoped to the slug) FAILs unless every source has tracks or a verified-empty record — and FAILs a footer that claims a source with no data.
  • No authority to SKIP a research step (Kyle, 2026-06-17). Doing the research is fine; skipping it silently is not — every mandatory step must leave a checkable artifact or the build fails. Beyond the sweep (sources.json) and route/class gates, check_report_ready.py (in --finalize) FAILs unless the frontmatter names HOW the judgment steps were verified: th_source (OSM / 14ers TH / recorded-track start — never memory), class_source (a beta URL: 14ers TR / Roach / climb13ers — not the peak_db summit grade), status_source (scrape_14ers_checklist / peak_db ascents — never assumed). A junk placeholder won't pass (the gate requires a recognized source token).
  • Verify IDs and trailheads from the source — peak_db can be wrong (Kyle, 2026-06-16). peak_db's peakbagger_id for Gladstone pointed at Mount Wilcox (pid 5667 vs the real 5817). Before using a cross-site id, confirm the page is the right peak (LoJ's peak page cross-links the correct peakbagger id). Confirm trailhead identity from OSM (Overpass: node[highway=trailhead] near the recorded track start), not from memory. Recorded-track starts are real data; the TH name/elevation must be verified, not inferred. Only connectors may be inferred — label them as such.
  • A PUBLISHED ROUTE IS CHECKED AGAINST PRIVATE LAND — and "nobody looked" is not permission (2026-08-15). check_route_access.py (a gate) FAILs a report whose committed route enters an osm_access restricted area, AND one that was never swept for access at all (no access_osm.json — run sweep_osm_access.py --slug <slug>). This closed two holes: the filter existed only in _candidates, so stitch_leg accepted restricted and ignored it — meaning stitched legs, which are most legs on a multi-peak day, were never checked — and nothing ever re-examined the line that actually shipped (a from_track recipe can name a recording straight through someone's ranch). OSM tags are imperfect, so a real easement is Kyle's call, recorded per-report with accept_access.py <slug> --area <osm-id> --reason "…"gpx/<slug>/route_accepted.yml. Never a global exemption inside the gate.
  • A COMPOSED route that gets HARDER must name the easier alternative (Kyle, 2026-07-30). compose_best_route optimises distance, gain and on-trail fraction — it has no notion of difficulty, so the cheapest stitch is often a direct RIDGE line between summits where the recorded parties dropped back down and came around on trail. That is a real difficulty increase bought with saved miles, and the reader must be told. So: whenever composing raises the class (or keeps the class but puts the line on connecting ridge instead of trail), the report must carry a note describing the easier option — the longer descend-and-reascend / trail variant, with its own mileage and class — so the choice is the climber's and not the optimiser's. Corollary: never let a composed line silently upgrade a day from Class 2 walking to Class 3–4 ridge scrambling; if that happens the harder grade becomes the headline class, per the rule below. See [[feedback-composed-route-difficulty-note]].
  • Class is SAFETY-CRITICAL — research the actual route, not the summit. peak_db yds_class is the per-summit STANDARD-route grade ONLY. For any traverse, ridge link-up, loop, or non-standard line the connecting terrain is often 1–2 classes harder (Mount Adams trio: Class 2 summits, Class 3–4 ridges; Cimarron PT 13,222 B is Class 4). Research the real grade from route beta (14ers route desc + trip reports
  • Roach + climb13ers) and set the headline to the hardest move on the recommended route. When unsure, take the harder estimate — under-stating class drives wrong gear/rope/helmet decisions. scripts/check_class.py --strict (in --finalize) FAILs any report whose class is below its hardest objective's peak_db class.
  • Verify inputs are COMPLETE before asserting any conclusion (Kyle, 2026-06-16). Almost every miss this session came from concluding on partial data — swept ONE peak's GPX library and called Wayah a shuttle (it's a loop); used summit class and called Adams "Class 2" (ridges are 3–4); one radius scan → "5 peaks" (6); read a logged-OUT 14ers page. Before stating a route shape, class, peak count, or status as fact, confirm: all objective peaks' GPX libraries swept + deduped · logged in (MCP browser) · whole cluster scanned (find_peaks_near.py) · route class from beta, not peak_db summit. Concluding from one source → label it provisional, not fact.
  • Scrape the report-climber's climbed status EVERY time — never assume (Kyle, 2026-06). Kyle: peak_db ascents (or find_peaks_near.py). Other climbers: scripts/scrape_14ers_checklist.py --climber <slug>. Set the report status from the scrape. (Regenerate a climber's home page with gen_index.py --climber <slug>.)
  • Identify peak clusters with scripts/find_peaks_near.py (--near "<peak>" or --center lat,lon) — lists ranked neighbors + climbed status + every source id. Not inline peak_db python.
  • Red !!! danger box only for Class 4/5 or a genuinely sketchy section (serious exposure, a notorious obstacle like the Clohesey 4x4 road). Ordinary Class 2/2+/3 days: just write the summary normally — no alarm box.
  • All 3 sources (14ers + LoJ + peakbagger) every time, + climb13ers for CO peaks, named in the report's "Sources checked" footer (CI lint enforces it).
  • Don't mark a report "researched" until the Cloudflare Pages deploy is green — verify with scripts/check_site_deploy.py --base https://mtn-research.pages.dev --strict (it fetches every expected path off that base — ~150 paths from the 3 mkdocs configs + maps + data). --base is required; it was omitted here until 2026-07-30, so the documented invocation exited 2 on argparse. Right after pushing a NEW report add --wait-min 6 — a new report carries a ~2 MB PNG plus its committed source tracks, and Pages can take longer to build than the default 2-minute retry budget (boskoff_group 404'd at 2 min, was green at 6). The waiting lives in the script on purpose: until curl …; do sleep; done is exactly the shell loop the ONE-CALL rule forbids. GitHub Pages is retired; wait_pages_deploy.py is deleted, so don't reach for it.

Where the detail lives

  • docs/architecture.md — full system map + the fast path.
  • docs/methodology/ — source-requirements, caltopo-pipeline, report-template, conventions, and per-workflow checklists (workflows/*.md).
  • peak_db client: /Users/kyleknutson/Library/Mobile Documents/com~apple~CloudDocs/shared/peak_db (Kyle's climbed-list + peak metadata; query via preflight.py/find_nearby.py, not inline python).