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 an iCloud-synced repo, 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 GitHub Pages.
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 (gitignored) | gpx/<slug>/, caltopo/<map_id>.json, notes/, scripts/cts.ini |
repo, gitignored | Personal data + credentials. Live only in iCloud. |
| 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)
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 + GitHub Pages + iCloud. 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.