Architecture & deployment¶
Last updated: 2026-06-15
What it is¶
A solo, file-based research pipeline that turns Kyle's "I want to climb that peak" into a published, version-controlled, link-shareable research dossier. Optimized for low-ops (no servers Kyle runs), reproducibility (every artifact derives from inputs in iCloud + a few external APIs), and Claude-driven workflow (one conversation produces one full research cycle).
It is not an app. It is a static site fed by a local git clone, with scripts that pull from one private DB and several public web sources. The "runtime" is the terminal during a Claude session; the "deploy target" is Cloudflare Pages (mtn-research.pages.dev).
Two facts here changed in July 2026 and the old ones are still worth knowing, because they explain the shape of the repo. The site used to deploy to GitHub Pages — that is retired and dark. And the working copy used to live in iCloud Drive; it now lives at ~/dev/mtn_research, because a synced .git was clobbered by concurrent sessions. The GPX library travels in git now (source tracks are committed as of 2026-07-28), which is how a second Mac gets it — not iCloud.
Components¶
| Layer | Component | Where it lives | Role |
|---|---|---|---|
| Authoring runtime | Claude session in mtn_research/ |
macOS terminal / Claude Code | The agent that drives the cycle: queries DB, reads TRs, writes markdown, runs scripts, commits |
| Canonical methodology | docs/methodology/*.md |
repo (published) | Source of truth for how research is done: workflows, source rules, CalTopo pipeline, report template, conventions, improvement plan. Read first by any session. |
| Memory | ~/.claude/projects/.../memory/*.md |
per-Mac local | A thin bootstrap pointer to docs/methodology/ plus a few live references (peak_db, playwright-mcp, 14ers-required, user-location). If memory conflicts with the repo docs, the docs win. |
| Peak data — source of truth | Supabase peak_checklist DB |
Supabase cloud | ~861 peaks + ascents + list memberships. Read-only here. Kyle's climbed list. |
| Peak data — client | peak_db_client.py |
~/Library/Mobile Documents/com~apple~CloudDocs/shared/peak_db/ (iCloud) |
Stdlib-only Python module + the read key. Shared across all Claude projects. |
| External research sources (×3, all required) | 14ers.com · listsofjohn.com · peakbagger.com (TRs, GPX, route/stat data, 14ers cell DB) | public web, logged-in | Driven via Playwright MCP (persistent profile, all 3 logged in). The Claude-in-Chrome extension is NOT used — it has an open allowlist bug. |
| Maps | CalTopo (interactive research maps + per-range regional "GPS Tracks" maps) | private maps | Read via fetch_caltopo.py; write via gpx_to_caltopo.py / sync_to_regional.py; restyle via restyle_markers.py (caltopo_python API). |
| Repo (iCloud-synced) | Projects/mtn_research/ |
iCloud Drive | Single source for code, docs, raw GPX, CalTopo dumps. Cross-Mac via iCloud sync. |
| Tooling | scripts/*.py |
repo, in scripts/ |
uv inline-deps for portable scripts; stdlib for the simple ones |
| Inputs | gpx/<slug>/ source tracks — IN GIT since 2026-07-28 (repo is private); derived GPX, caltopo/<map_id>.json, notes/ stay gitignored |
local clone ~/dev/mtn_research |
The swept beta travels in git now — that is how a second Mac gets it, not iCloud. caltopo/ is a re-fetchable cache (84 map dumps, ~937 MB, one file 291 MB — too big and too churny to commit, and CalTopo itself is authoritative). Credentials live OUTSIDE the repo at ~/Library/Mobile Documents/com~apple~CloudDocs/mtn_research_data/cts.ini, symlinked in as cts.ini. |
| Outputs (versioned) | docs/**, climbers/*.yml, mkdocs.yml, overrides/, scripts |
repo, in git | The publishable artifacts + per-climber profiles + scripts. |
| CI/CD | .github/workflows/docs.yml |
repo | On push to main: lint (source-rigor gate, check_reports.py) → deploy (scripts/deploy_site.py → Cloudflare Pages). Lint failure blocks the deploy. |
| Hosting | Cloudflare Pages | mtn-research.pages.dev |
Free, custom-domain-capable, static-only. Sibling of the mtn-share shares project (same account/wrangler auth). Doesn't require the repo to be public. |
| Source repo | GitHub kylegknutson/mtn-research |
github.com/kylegknutson/mtn-research |
Private (post-CF-migration); carries the gpx data so a clone is self-sufficient. |
Deployment diagram¶
┌──────────────────────────────────────────────────────────────────────────────┐
│ CLAUDE SESSION (any Kyle Mac) │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Claude (Sonnet, Code CLI) │ │
│ │ ▲ │ │
│ │ │ reads on every session start │ │
│ │ │ │ │
│ │ ├─── memory bootstrap → docs/methodology/ (canonical workflow docs) │ │
│ │ │ │ │
│ │ ├─── Playwright MCP (persistent profile; 14ers + LoJ + peakbagger │ │
│ │ │ all logged in) · Chrome extension unused (allowlist bug) │ │
│ │ │ │ │
│ │ └─── Bash / Edit / Write / Grep / Read tools │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└────┬────────────────────────────────────────────────────────────────────────┬─┘
│ │
│ query peaks/ascents (read-only) │
▼ │
┌──────────────────────────────────┐ │
│ ~/Library/.../shared/peak_db/ │ (iCloud-synced, NOT in repo) │
│ ───────────────────────────── │ │
│ peak_db_client.py (stdlib) │ │
│ peak_checklist.env (secret_key)│──┐ │
└──────────────────────────────────┘ │ │
│ HTTPS / PostgREST │
▼ │
┌──────────────────────────────┐ │
│ Supabase (peak_checklist) │ │
│ ─────────────────────── │ │
│ peaks (861) ascents (591) │ │
│ peak_lists (946) │ │
│ ← written by separate │ │
│ peak_checklist webapp │ │
└──────────────────────────────┘ │
│
┌──────────────────────────────┐ │
┌─────────────── │ Public web (HTTPS) │ ──────────────────────┤
│ │ ─────────────────────── │ │
│ Playwright │ • 14ers · LoJ · peakbagger │ CalTopo (research + │
│ MCP (logged │ (TRs, GPX, stats, cell) │ regional maps, │
│ in ×3) │ • Google Maps (drive time) │ caltopo_python API) │
│ ◀────────────│ • CalTopo private maps │ ─────────────────────▶ │
│ └──────────────────────────────┘ │
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ REPO: ~/Library/.../Projects/mtn_research/ │
│ (iCloud-synced; git-tracked) │
│ │
│ ┌──────── gitignored (personal data) ────────┐ ┌──── tracked in git ───┐ │
│ │ │ │ │ │
│ │ gpx/<slug>/*.gpx │ │ docs/peaks/*.md │ │
│ │ caltopo/<map_id>.json │ │ docs/maps/*.png │ │
│ │ scripts/cts.ini │ │ scripts/*.py │ │
│ │ notes/*.md (WIP planning) │ │ mkdocs.yml │ │
│ │ │ │ README.md │ │
│ └─────────────────────────────────────────────┘ └───────────────────────┘ │
│ │
│ scripts/ (uv inline-deps, portable) │
│ ┌─────────────────────┬──────────────────────┬──────────────────────────┐ │
│ │ make_overview_map │ fetch_caltopo │ find_nearby │ │
│ │ (Pillow+OSM tiles, │ gpx_to_caltopo │ combo_stats │ │
│ │ source-colored) │ sync_to_regional │ drive_time │ │
│ │ caltopo_to_gpx │ restyle_markers │ check_reports (CI gate) │ │
│ │ build_*_gpx │ delete_caltopo_marker│ (all caltopo_python/std) │ │
│ └─────────────────────┴──────────────────────┴──────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────────────┘
│
│ git push origin main (via gh auth)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ GitHub: kylegknutson/mtn-research │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ main branch │ ─────▶ │ GitHub Actions: docs.yml │ │
│ │ (markdown, PNGs, │ │ - lint: check_reports.py (gate) │ │
│ │ scripts, mkdocs.yml)│ │ - build: mkdocs │ │
│ └──────────────────────┘ │ - deploy: Pages artifact │ │
│ └─────────────┬────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ gh-pages branch (static HTML) │ │
│ └─────────────┬────────────────────────┘ │
└──────────────────────────────────────────────────┼──────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Cloudflare Pages CDN │
│ mtn-research.pages.dev │
│ │
│ Reader (Kyle's iPhone, friends, etc.) │
└──────────────────────────────────────────┘
Legend:
- Solid lines: same-machine / same-trust boundary
- Arrows across the boundary: network hop with auth (token / cookie / key)
The orchestrated build — sequence¶
Every report build/rebuild runs through build_report.py (2026-08; renamed from
rebuild_report.py — one tool builds and rebuilds, a fresh slug just makes quarantine a
no-op). Claude only ever executes the orchestrator; the guarded build scripts refuse any
caller without the orchestrator's run token. Kyle's decisions enter as data
(deviations.yml) and persist across rebuilds.
sequenceDiagram
autonumber
participant K as Kyle
participant C as Claude
participant O as build_report.py<br/>(orchestrator)
participant S as guarded build scripts
participant G as gates
participant X as sources · CalTopo · Pages
C->>O: --start slug
O->>S: preflight.py
O->>O: quarantine old artifacts (outside repo) + strip nav + seed peaks.yml
Note over O: run token minted · status.html goes live
O-->>C: stop — sweep needed
C->>X: 3-source browser sweep (login-gated, false-empties refused)
C->>O: --advance
O->>O: verify sweep freshness + sources.json
O->>S: scaffold --force · build_peak_gpx · find_osm_trailheads → evidence
O->>O: re-apply Kyle's deviations (materialize)
O-->>C: stop — research (fill peaks.yml: TH, days/recipe, research:)
C->>O: --advance
O->>S: once-per-run prep: OSM sweeps · compose --why → evidence
O->>S: compose --apply (skipped for from_track and from-camp days)
O->>S: build_route / build_trip_day_routes (+ leg corridors)
O->>G: route gates (recipe · fidelity · composition · seasons)
O->>S: build_report_steps (data, captured) — new CalTopo map minted
O->>S: write_report.py → skeleton with NARRATIVE-TODO markers
O-->>C: stop — prose (replace markers, Edit tool only)
opt a research decision changes (Kyle's verdict · composer evidence · a defect fix)
C->>O: --rewind (Claude/Kyle only — refused once the publish commit lands)
Note over O: phase → research · route prep + downstream stamps cleared<br/>status page banners the rewind · pre-rewind time stays in the total
C->>O: --advance — route/data re-run · a TODO-marked skeleton is regenerated,<br/>finished prose is never overwritten
end
C->>O: --advance
O->>G: prose gates (surviving markers refused) · finalize: all gates
O->>X: ONE atomic commit + push · deploy_site · verify deploy
O-->>C: stop — at finish
C->>O: --finish
O->>X: delete old + superseded CalTopo maps · remove quarantine
O->>O: build-log entry (timing · issues · mid-run gate changes)
K-->>C: verdicts on published review maps (markers, groupings, strays)
C->>O: recorded as deviations.yml — carried into every future rebuild
Any gate failure stops --advance with the problem verbatim (also on the live status page).
Rewind is not error-handling — a failed gate means bad data (fix the inputs) or a code
bug (fix the script, add a test). --rewind exists for one case: a research-phase judgment
changes after later evidence — Kyle rejects a grouping/marker, the composer's evidence beats
the recipe Claude picked, or a defect fix invalidates built artifacts. The trigger can
surface anywhere, but the call is always Claude's or Kyle's (the orchestrator never rewinds
itself), the target is always research (where every judgment input lives), and it is refused
after the publish commit — post-publish changes are a new run. --abort restores the
quarantined report byte-identically. The numbered list below predates the orchestrator
and remains as the underlying mechanics reference.
One research cycle, step by step¶
Fast path (low-prompt):
preflight.py(flight check: resolve peaks, creds, login indicators → GO/NO-GO) → in-chat GPX sweep +ingest_gpx.py→scaffold_report.py(dir + peaks.yml) →build_report.py(one command: waypoints → CalTopo cross-ref → combo stats → drive → PNG → CalTopo map → regional sync) → write prose →build_report.py --finalize(climber-status + index + QA gates) → commit → push (the one deliberate publish gate). The mechanical work lives inside these allowlisted scripts, so a build has two human touch points: approve the flight-check, approve the publish. See caltopo-pipeline §Fast path. The numbered steps below are the detailed reference the fast path chains.
- Kyle: "Find me unclimbed Sangre 13ers under 4,000 ft / 8 mi" (narrow-down) or "do a report on Peak X" (report).
- Claude reads
docs/methodology/(pointed to by the memory bootstrap) → knows the workflow, source rules, pipeline, conventions. - Login check — confirm all 3 sources logged in via Playwright (per source-requirements). No 14ers → halt.
- DB query via
peak_db_client.peaks(...)+ascents()→ candidates; cluster analysis (nearby unclimbed ranked, same-drainage flag). - TR + GPX sweep across all 3 sources (14ers + LoJ + peakbagger) via Playwright → route stats, combo signal, every GPX track.
- Stats / cell / drive →
combo_stats.py(distance/gain), 14ers cell DB,drive_time.py(Maps link from the climber's home). Headline distance/gain comes from the measured GPX, never climb13ers prose (seesource-requirements.md);check_route_stats.pyaudits this. - Authoring →
docs/peaks/<slug>.md(ordocs/trips/for multi-day) per the report template, ending with the "Sources checked" footer. - Maps (required, ship with the report):
gpx_to_caltopo.py --gpx-dir gpx/<slug> --new-map "Research: …" --no-dedupe→ research map (summit=bluepeak, others=graypoint, tracks source-colored).sync_to_regional.py --slug <slug> --map-id <regional>→ also push tracks into the range's regional "GPS Tracks" map.build_recommended_route.py <slug>(optional, multi-track peaks) →gpx/<slug>/<slug>_recommended.gpx: shortest route through only the ranked objectives (add-on peaks trimmed), via a pooled-track graph router that splices real segments across parties' tracks at crossings, either direction (--legsfor the older per-leg method); distance from the route GPX, gain resampled from a DEM (matches CalTopo — GPX<ele>is too noisy). Renders as the bold-magenta "recommended route (composed)" line.- Teleport guard (Kyle caught one by eye 2026-06-15). The graph router can shortcut a long approach with a multi-mile straight-line jump (cheaper than following the trail) — invisible to the pixel gates.
check_route_geometry.pyflags any within-segment consecutive-point jump > 1 mi as a FAIL (wired into--finalize). Fix a flagged route with--legs, or split genuinely-disconnected objectives into separate<trkseg>s (e.g. two individual climbs that share no recorded connector — the gate checks within a segment, so deliberate breaks are allowed and never invent a straight-line connector across terrain no party recorded).
- Teleport guard (Kyle caught one by eye 2026-06-15). The graph router can shortcut a long approach with a multi-mile straight-line jump (cheaper than following the trail) — invisible to the pixel gates.
make_overview_map.py <slug>→docs/maps/<slug>.png(source-colored, objective-framed; magenta recommended route on top if present).- Wire up → map IDs + PNG into the markdown;
image:frontmatter for the link preview; mkdocs nav. - Structured stat frontmatter (
dist_mi,gain_ft,class,peaks,days,drive_h, anddays_detailfor trips) is the single source of truth for the stat surfaces.gen_quickstats.pyrenders the top "At a glance" callout;gen_index.pyemits the sortable index table (numeric columns + tablesort) anddocs/data/report_stats.json, whichnav-stats.jsreads to append a compact badge under each report's left-nav link. All three have--checklints. - Commit + push → Actions runs lint (source gate) → build → deploy; Pages updates in ~1 min. Don't mark "researched" until the deploy is green.
- Result: a public URL with a link-preview card, openable from the phone at the trailhead, plus interactive CalTopo research + regional maps.
Concepts added since the original design¶
- Methodology is canonical and in-repo.
docs/methodology/(workflows, source-requirements, caltopo-pipeline, report-template, conventions, improvement-plan) is the source of truth. Memory is a thin pointer. → reproducible on any Mac, no chat history. - Three sources, always. 14ers + LoJ + peakbagger must each be logged-in and checked; a "Sources checked" footer asserts it and the CI lint gate enforces it.
- Browser = Playwright MCP (persistent profile), not the Claude-in-Chrome extension (open allowlist bug).
- CalTopo: research + regional maps. Each report has a focused research map; tracks are also synced into a per-range regional "GPS Tracks" map (registry in
caltopo-pipeline.md). Marker scheme: summit = bluepeak(#2E78C7), everything else = graypoint(#9E9E9E); tracks colored by source. - Multi-climber.
climbers/<slug>.ymlholds home address (drive-time origin) + climbed-list source (peak_db for Kyle, 14ers checklist for friends). Friends get per-(peak,climber)reports and their own published site. (Scaffolded; not yet exercised end-to-end.) - Report flavors. Single-peak · day-trip (multi-peak, one outing) · multi-day backpack (
docs/trips/). - Narrow-downs as artifacts. Saved to
docs/lists/(snapshot + re-runnable criteria) rather than chat-only. (Planned — see improvement-plan.) - Site polish. Flatirons favicon + home logo; per-report Open Graph link previews (own overview map as the card) via
overrides/main.html; external links open in a new tab. - Interactive peak map (home +
/peak-map/).scripts/gen_peak_map.pyreads every ranked Colorado peak from peak_db, plus the climb log (ascents()), and assigns each a status: report (viagpx/<slug>/peaks.ymlobjective_idsor apeak_ids:frontmatter fallback) → climbed → to-do. Writesdocs/data/peaks.json(committed — the static deploy serves it; CI never touches peak_db). A Leaflet map (docs/javascripts/peak-map.js, CDN lib inmkdocs.yml; paths resolved from the script URL so it survives Material instant-nav) draws reported peaks as a green CalTopo-stylepeakicon (#39FF14, click → report), climbed as grey dots, to-do as red dots, on toggleable layers (canvas-rendered for speed). Regenerated inbuild_report --finalize. - Climbed-status refresh (peak_db → site). Climbed status is a build-time snapshot, not live — peak_db powers it but the site only changes when the derived outputs are regenerated and pushed.
scripts/refresh_from_peakdb.pyis the one-command pipeline for this:gen_peak_map.py(map data) →sync_status.py(report frontmatterstatus:← whether all itsobjective_idsare climbed; drives the index Status column) →gen_index.py(table + nav badges), then commits + pushes the changed outputs (→ Pages redeploys). It's idempotent and meant to run on a Mac as the post-processing step after the peak-checklist app updates peak_db (the Mac already has the client + key; no GitHub secret or cloud trigger needed).--dry-run/--no-pushavailable. (The report body's prose "Status in DB:" line is editorial and stays manual.)
What's reproducible vs. authoritative¶
| Artifact | Source of truth | Regeneratable? |
|---|---|---|
peaks / ascents rows |
Supabase (written by separate peak_checklist webapp) | No — owned upstream |
Downloaded GPX in gpx/<slug>/ |
14ers + LoJ + peakbagger (logged-in, via Playwright) | Yes — re-sweep all 3 sources |
CalTopo JSON dumps in caltopo/ |
Kyle's CalTopo account | Yes — fetch_caltopo.py |
Overview PNGs in docs/maps/ |
derived from gpx/<slug>/ + OSM tiles |
Yes — make_overview_map.py |
Markdown in docs/peaks/ |
Claude + Kyle, version-controlled in git | Authoritative — this is the work product |
| Published mkdocs site | derived from markdown via Actions | Yes — git push triggers rebuild |
| CalTopo research maps (cloud) | Kyle's CalTopo account, written by gpx_to_caltopo.py |
Yes — re-run script with same GPX |
The research markdown is the only thing that isn't trivially regenerable. Everything else is a derived artifact or an upstream mirror. That's the key property — losing the laptop costs nothing if iCloud has synced (and even iCloud loss is fine if git has the markdown).
Trust & secrets¶
| Secret | Where | Risk if leaked |
|---|---|---|
SUPABASE_SECRET_KEY (peak_db) |
iCloud-only shared/peak_db/peak_checklist.env |
Full RW on Kyle's climb DB. Never in git, never in chat. Rotate via Supabase dashboard. |
| CalTopo credentials | scripts/cts.ini (gitignored) |
Full account access to maps. |
| GitHub PAT | gh keyring (system) |
Repo push access. Per-Mac via gh auth login. |
Everything else (research markdown, public GPX, OSM tiles, etc.) is public-by-design.
Why this stack¶
- No server Kyle runs. Supabase + Cloudflare Pages + a local git clone. The only thing maintained is the markdown and scripts.
- Multi-Mac portability solved twice. iCloud sync handles the repo and personal data;
uv+ghhandle the per-Mac toolchain (onebrew installapiece, then nothing to remember). - Claude is in the loop, not a one-shot. Memory files mean every session starts informed. The workflow recipes are versioned outside the project repo (in
~/.claude/projects/.../memory/), so they evolve with the agent. - Public output, private inputs.
.gitignorecleanly splits "research notes for the world" from "Kyle's personal GPX + CalTopo data." Same repo, two audiences.
This page is regenerated whenever the architecture changes — scripts added/removed, deployment target moves, secrets rotated, etc. Update the "Last updated" date at the top when editing.