# Claude Coach Bootstrap Skill

> Source: <https://gist.github.com/brendanlong/1d86d44963327bc00fb5d845f04770ab>
> Published: 2026-07-06 22:44:42+00:00

| name | coach-repo-bootstrap |
|---|---|
| description | Interview a user and scaffold a personalized git-based repo where an LLM acts as their ongoing strength-and-conditioning coach, with all state kept in files rather than the model's context. Use this whenever someone wants to set up a workout or training tracker, a personal-coach or AI-fitness-log repo, an RPE-autoregulated strength program, or any long-running per-session tracking system an agent maintains across sessions — even if they never say "skill," "scaffold," or "repo," and even if they only describe the outcome ("I want an AI to plan and log my workouts and handle the progression for me"). |

Interview the user, then scaffold a git repo where an LLM coaches them across sessions with all
state in files. Work in order — **interview (§1), scaffold (§2), verify (§5)** — and don't
assume their goals, schedule, equipment, or body: a wrong guess baked into the scaffold costs
more to undo than a question costs to ask. The Gotchas (§4) encode real failure modes; treat
them as load-bearing.

The coach has no memory between sessions, so continuity lives in the repo, not the model: each session an agent reads a few small files, reasons, writes conclusions back, and forgets. Three rules keep that loop working over months, and they drive every choice below:

**Small working memory**— the state needed to plan a session fits in a few kilobytes, reloadable from scratch each time; detailed history is archived, not re-read.**Write each fact once**— one canonical home per fact; everything else points to it.** External guardrails**— a schema, a validator, and CI, because the model silently drifts its own conventions over time (see Gotchas).

Ask these in small batches (don't dump all of them at once). Adapt follow-ups to their answers. Your goal is to fill in the templates in Phase 2, so listen for those values.

**A. Goals & context**

- What do you want out of training? (strength, size, endurance, general health, a specific event/deadline, rehab from an injury…)
- Experience level and background? (never trained / returning / experienced / coming from a specific modality like CrossFit, running, lifting)
- Anything time-bound? (a race, a trip, a season starting)

**B. How much structure do you want** — *this is the key axis; don't skip it.*
Programs live on a spectrum from fully-planned to fully-improvised. Ask where they want to be:

**Fixed**— "tell me exactly what to do each day." A written weekly template with prescribed days. Predictable; brittle if their week is chaotic.**Scaffolded**— "give me a default week, but adapt when life happens." A soft weekly shape plus a library the coach composes from.*Good default for most people.***Flexible**— "just tell me the best thing to do on the day I show up." A library of exercises + a few hard constraints; every session composed on the fly from what's stale. Lowest overhead; needs the staleness tooling to stay coherent.

Tell them they can start anywhere and move along the spectrum later (many people start Fixed and loosen once fixed days stop fitting their week). Record their choice; it decides which program blocks you generate in Phase 2.

**C. Schedule reality**

- How many sessions/week can you
*realistically*hit (not aspirationally)? - Typical session length? Are short 15–25 min sessions acceptable, or only "real" workouts?
- Fixed training days, or does it move around week to week?
- Any recurring activities that already train something? (a sport, a weekly class, a commute-by-bike). These matter — they can "cover" a training goal so the coach doesn't double-program it. Ask what each one demands physically and roughly how hard/long it is.
- What
*kinds*of sessions will you log? Start from`strength`

and`cardio`

, then add every named activity they do (yoga, a specific sport, a class, a swim). This answer becomes the log`type`

set and the validator's`VALID_TYPES`

(§3.2) — if you leave it at the generic default, the validator will reject the user's real activities or the coach will silently flatten them into a lossy`sport`

.

**D. Locations & equipment** — one profile per place they train

- Where do you train? (home, a specific gym, outdoors, travel…)
- For each: what equipment is actually there? Be concrete (dumbbell range and increments, machines, bars, bands, cardio options, floor space).
- Safety constraints? (no spotter → avoid movements where failure is dangerous; no rack; etc.)

**E. Constraints & health** *(ask gently; all optional; note only what they volunteer)*

- Injuries, past or present, or movements that hurt?
- Medical conditions or medications that affect training, heart rate, hydration, or recovery? (If they share these, record them so the coach programs around them — but don't press.)
- Anything you specifically want to avoid or emphasize?

**F. Logging & tracking preferences**

- How much detail do you want in logs — terse, or a full narrative per session?
- Do you want personal records tracked?
- Wearables: there's no clean way to auto-link a wearable, so don't build around one. But if
they use one, mention that a
**screenshot** of the wearable's session summary (HR, zones, calories) is easy to drop into a session and read, so they can share those whenever they want the data captured. Treat those numbers as directional, not gospel (see Gotchas).

**G. Progression philosophy** — explain and confirm

- The default is
**RPE-based autoregulation**: each exercise has a target rep/RPE range and a trigger ("add weight when all sets hit the top of the range at RPE ≤ 8"). Load goes up when they clear the bar, holds when they're grinding. No fixed weekly percentages, no calendar deloads — deload by feel when signals stack up. - Briefly teach the RPE scale so their logged numbers mean something:
`6`

= 4+ reps left ·`7`

= 3 left ·`8`

= 2 left ·`9`

= 1 left ·`10`

= max, nothing left. - Confirm this suits them, or adjust (some people want fixed linear progression instead).

When the interview is done, **play back a summary** of what you heard and get confirmation
before scaffolding.

**Before writing any files, build the exercise library from the interview** — the specific
movements they'll actually train, from their goals, equipment, and chosen structure level.
Each library entry carries a `movement_pattern`

tag (a muscle-group/goal like `squat_bilateral`

or `cardio_easy`

); **those tags are the pattern vocabulary the whole repo keys off** — the
staleness briefing and the validator both derive it from the library, so there's no separate
pattern list to maintain. Tag only patterns they'll really train (a walker who lifts twice a
week has no `cardio_intervals`

), and getting the library from the user rather than this doc's
examples is what keeps the scaffold from overfitting.

Also note: **every concrete example here — yoga covering core, a weekly sport banking interval
cardio, HR-elevating medication — is an illustration, not a default.** Substitute the user's
real activities and constraints and delete anything that doesn't apply to them.

Then create this structure. Fill every template from the interview answers, and delete blocks that don't apply to the structure level they chose (Fixed / Scaffolded / Flexible).

```
their-coach-repo/
├── CLAUDE.md                 # operating manual for future session-agents (see §2.6)
├── README.md                 # short human-facing overview
├── profile.yaml              # who they are, goals, schedule, constraints
├── equipment/
│   └── <location>.yaml       # one per training location
├── program/
│   └── current.yaml          # the exercise library + program state + pattern coverage (the one source of truth)
├── logs/
│   ├── TEMPLATE.yaml         # canonical session-log schema (copy per session)
│   └── prs.yaml              # personal records, keyed by exercise_id
├── scripts/
│   ├── staleness.py          # session-start briefing (derives everything from current.yaml)
│   └── validate.py           # schema check (also runs in CI)
└── .github/workflows/validate.yml
```

Initialize git, make the first commit after everything validates, and (if they use GitHub) push and optionally open it as a repo.

```
# Athlete Profile
name: "<name>"
goals:
  - "<their goals, one per line>"
experience: "<background>"

schedule:
  approach: "<Fixed | Scaffolded | Flexible> - <one-line description of what that means here>"
  realistic_frequency: "<e.g. 2-3 strength + 1 cardio per week>"
  session_length: "<e.g. 20-45 min; short sessions OK>"
  recurring:
    # activities that already train something - the coach credits these (see pattern_coverage in current.yaml)
    "<e.g. saturday>": "<e.g. yoga class with partner>"

preferences:
  - "<logging: voice or text>"
  - "<how much detail they want>"
  - "<anything else they told you>"

# Only include if they volunteered health/medical info. Program around it; don't over-share.
constraints:
  injuries: []          # e.g. "left knee - patellofemoral pain on deep lunges"
  medical: []           # conditions / meds that affect training, HR, hydration, recovery
  avoid: []             # movements or patterns to skip
name: "<Home Gym | Anytime Fitness | ...>"
location: <short-id-matching-log-location>   # e.g. home, gym, travel

strength:
  - "<be specific: dumbbell range + increments, bars, machines, bands, bench, rack...>"
cardio:
  - "<treadmill, bike, rower, outdoor routes, jump rope...>"

notes: |
  - Safety: <e.g. no spotter - avoid movements where failure is dangerous>
  - <anything about loading increments, quirks, what's missing>
```

This is the source of truth for current working weights and trends. The **exercise library**
is common to all three structure levels; the scheduling blocks differ.

```
name: "<program name>"
start_date: <YYYY-MM-DD>
phase: "<free text, e.g. 'Base building (RPE-driven)'>"

# Progression is autoregulated by RPE trigger per exercise, not by calendar week.
progression_plan: |
  Load goes up when an exercise's `progression` trigger is met (e.g. all working sets at the
  top of the rep range, RPE <= 8, clean form). Working RPE lives in the 7-9 band. RPE 9 on a
  top set is fine; RPE 9 on every set means hold, don't add. Deload by feel when signals stack
  up (multiple lifts stalling, RPE 9-10 everywhere, poor recovery) - no scheduled deload week.

# ==================== EXERCISE LIBRARY ====================
# The single source of truth for working weights, trends, AND recency. current.last_session is
# what the staleness script reads to date each movement pattern - so it's written once, here,
# and never duplicated into a separate tracker. Keep each current.trend to ~2 sentences (last
# result + next trigger); the full narrative lives in the session log.
exercises:
  <exercise_id>:                        # stable snake_case key, e.g. bench_db, squat_goblet
    name: "<display name>"
    variant: "<canonical description, e.g. 'Flat, dumbbell'>"
    movement_pattern: <pattern_key>     # a muscle-group/goal tag; the set of these tags across
                                        # the library IS the pattern vocabulary (staleness + validator derive it)
    role: primary                       # primary | accessory | finisher | fallback
    target: "3x6-8 @ RPE 7-8"
    progression: "Add ~5 lb when all sets hit 8 reps at RPE <= 8 with clean control"
    notes: "<cues, substitutions, form flags - optional>"
    current:
      weight: "<e.g. '45 lb each' or null if not yet trained>"
      last_session: null                # YYYY-MM-DD, set after first time trained
      trend: "<2 sentences: last result + next trigger. Empty until first session.>"
  # ... one block per movement they'll train. Start with a handful; add more as needs surface.

# ==================== CARDIO ====================
# Cardio doesn't compose with strength lifts, so it lives separately as templates-with-state.
cardio:
  <cardio_id>:                          # e.g. zone_2, intervals
    name: "<e.g. Easy Cardio (Zone 2)>"
    intensity: easy                     # easy | hard
    movement_pattern: <pattern_key>
    modality: "<walk/run, bike, row - whatever's available>"
    pacing: "<e.g. talk test - full sentences>"
    progression: "<e.g. add 5 min every 2 weeks; build duration over speed>"
    current: { duration: null, last_session: null, trend: "" }

# ==================== SESSION SHAPES ====================
# Rough sizes the coach composes from - not prescriptive.
session_shapes:
  quick:    "15-25 min. 1-2 exercises hitting one stale pattern."
  standard: "25-45 min. 1 primary + 1-2 accessories + optional finisher."
  big:      "45-60 min. 2 primaries + 2-3 accessories + finisher."

# ==================== PATTERN COVERAGE ====================
# Which OTHER patterns also train a goal when performed. The staleness script uses this to
# compute EFFECTIVE staleness so the coach doesn't nag about a goal already trained indirectly
# (e.g. a weekly yoga class covers `core`; a sport banks `cardio_intervals`). This is static
# config - set it once from the recurring activities in the interview; it needs no per-session
# updates. Every key and every covered_by entry must be a pattern some library entry tags.
pattern_coverage: {}
  # Example:
  # core:
  #   covered_by: [yoga]
  #   note: "Weekly class is core-heavy - core is trained even when a plank isn't programmed."
  # cardio_easy:
  #   covered_by: [yoga]
  #   partial: true          # covers the goal but doesn't fully replace a dedicated session

# ==================== SCHEDULING (pick ONE block per the chosen structure level) ====================

# --- FIXED: an explicit weekly template ---
weekly_template:
  monday:    { name: "Lower A",  exercises: [squat_id, hinge_id, core_id] }
  wednesday: { name: "Upper A",  exercises: [bench_id, row_id, ohp_id] }
  # ...one entry per training day. The coach follows this unless the user deviates.

# --- SCAFFOLDED: a soft default shape + hard constraints ---
weekly_scaffold:
  shape: |
    A default *shape* for a normal week, not a fixed calendar. When the week is chaotic, fall
    back to on-the-fly composition (staleness + energy + time). Example:
      2 lower/upper strength days + 1-2 cardio, arranged around the week's fixed points.
  constraints:
    - "<e.g. no hard legs in the 2 days before the Saturday yoga class>"

# --- FLEXIBLE: library + constraints only, composed on the fly ---
composition_rules: |
  No weekly template. Each session: (1) run scripts/staleness.py, (2) ask energy/time,
  (3) pick 2-4 exercises hitting the stalest patterns that fit the session shape and don't
  pile onto a beat-up body, (4) propose and confirm before starting.
constraints:
  - "<hard rules that always hold, e.g. leg-freshness before a balance-heavy class>"

weekly_targets:
  strength: "<e.g. 2 sessions minimum>"
  cardio: "<e.g. 1 easy + 1 hard>"
  recovery_floor: "After 3+ hard days in a row, take a true rest or easy day."
```

Only keep the scheduling block matching their chosen level. Everything above

`SCHEDULING`

is shared. If they're unsure, useScaffolded— it degrades gracefully in both directions.There is

no separate recency tracker file. Pattern staleness is derived from each library entry's`current.last_session`

, and`pattern_coverage`

lives in this same file — so a session only ever updates`current.yaml`

(and`prs.yaml`

), never a hand-pruned session list.

```
# Session Log TEMPLATE. Copy per session: logs/YYYY-MM-DD.yaml
# (add a -slug suffix for a 2nd session same day: logs/YYYY-MM-DD-ohp.yaml).
# Keep field NAMES verbatim so logs stay parseable. Run scripts/validate.py after writing.
#
# Required: date, day_of_week, type, location. Strength sessions also need `exercises:`;
# non-strength sessions need a narrative `notes:` block.

date: 2026-01-01           # ISO date in the USER'S timezone (see Gotchas - don't use UTC)
day_of_week: Wednesday     # look up: TZ="<their/timezone>" date -d 'YYYY-MM-DD' '+%A'
type: strength             # strength | cardio | yoga | sport | rest  (customize the set)
location: home             # matches an equipment/ profile id, or a free label
duration: "~30 min"        # single canonical field (NOT duration_min / duration_estimate)
focus: "Short description of the session's intent"
energy: moderate           # low | moderate | high (optional)

notes: |                   # ONE narrative field (not session_context/session_notes/how_felt)
  Why composed this way, anything notable, pain/form flags, recovery context, what's next.

# Strength sessions:
exercises:
  - name: Bench Press
    exercise_id: bench_db          # REQUIRED - a program/current.yaml key (the stable match
                                   #   key), or null for a genuine one-off not in the library
    variant: "Flat, dumbbell"      # REQUIRED - exactly how it was done today (free text)
    movement_pattern: horizontal_push   # must match a movement_pattern tag used in program/current.yaml
    sets:
      - { reps: 8, weight: "45 lb each", rpe: 8 }
    notes: "How it compared to last time; progression-gate status; what to load next."

# Non-strength sessions: use a narrative notes: block above and optionally:
# session:      { name, start_time, end_time, source, rpe }
# heart_rate:   { avg_bpm, calories, zone_distribution, summary }   # directional only
# recovery_context: { previous_strength, previous_legs_loaded, notes }

# PRs hit this session. ALWAYS a list of objects (never bare strings). Mirror into prs.yaml.
prs:
  - { exercise: "Bench Press", variant: "Flat, dumbbell", type: rep_pr, detail: "8 @ 45 lb (up from 40)" }
# Personal records, keyed by exercise_id (a program/current.yaml key). Retired movements use
# a legacy_* key. Each entry's notes carry the specific variant/implement.
records: {}
  # Example:
  # bench_db:
  #   rep_pr:
  #     - { reps: 8, weight: "45 lb each", date: "2026-05-24", notes: "up from 7 on 5/14" }
  #   volume_pr:
  #     - { sets: 3, reps: "8/8/9", weight: "45 lb each", date: "2026-05-24" }
```

Generate a CLAUDE.md so every future session an agent knows how to run. It should contain, customized to this user:

**Role & timezone.**"You are this person's strength coach. Their timezone is`<tz>`

— the system date may be UTC; convert before naming log files. Look up day-of-week with`TZ="<tz>" date -d 'YYYY-MM-DD' '+%A'`

, don't guess."**Core principles.** Progressive overload; RPE autoregulation; equipment-awareness; track variants not false equivalences; recovery matters; compose per the chosen structure level.**Session workflow — starting.** Run`python3 scripts/staleness.py`

first (never hand-compute staleness). Read the relevant`equipment/`

profile and`program/current.yaml`

. Ask energy/time. Propose 2-4 exercises (or one cardio) and confirm before starting.**Session workflow — during.** Confirm any ambiguous numbers back before recording; ask RPE if omitted; note pain/form issues. (If they paste a wearable screenshot, read HR/zones/ calories off it into the log; treat those as directional.)**Session workflow — after.**(a) Copy`logs/TEMPLATE.yaml`

→`logs/YYYY-MM-DD.yaml`

. (b) Update each trained exercise's`current`

in`program/current.yaml`

(weight/duration,`last_session`

= today, 2-sentence trend; bump`target`

if the trigger was met). This is the only recency bookkeeping — the staleness script reads`last_session`

, so there is no separate tracker to update. (c) Update`logs/prs.yaml`

for any PRs. (d) Run`python3 scripts/validate.py`

and fix errors. (e) Commit; push; open a PR titled`Workout: <focus> - <date>`

with a summary.**The write-once rule.** Full narrative → the log. 2-sentence state + next trigger →`current.trend`

. Recency is*derived*from`current.last_session`

, not written anywhere separately. Never paste the same paragraph into two files.**Notation standards**— restate the units and`exercise_id`

/`variant`

rules from Gotchas 4–5 here, but addressed to the*session-agent*(not the builder), as the rule + one example, not the Gotchas commentary. This is the one deliberate exception to write-once: CLAUDE.md is the manual the session-agent actually reads each time, so it needs its own local copy of the conventions rather than a pointer into this build spec.**RPE scale** and any**health constraints** to program around.

Keep it tight and imperative. It's read every session — it's part of the working memory.

Drop in `scripts/staleness.py`

and `scripts/validate.py`

(§3), and the CI workflow:

```
# .github/workflows/validate.yml
name: Validate logs
on: { push: { branches: [main] }, pull_request: {} }
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install pyyaml
      - run: python3 scripts/validate.py
```

Both are plain Python + PyYAML (`pip install pyyaml`

). They read the files you generated.
Customize the config constants at the top of `validate.py`

before shipping: set `VALID_TYPES`

from the session-types answer in Phase 1 (§1 C) — leaving the default five will reject the
user's real activities — and set `GRANDFATHER_DATE`

to the repo's start date. Note the
validator enforces the *names and shapes* of required and PR fields, not the presence of every
optional field (e.g. it flags legacy `duration_min`

but doesn't require `duration`

itself), so
it's a drift guard, not a completeness checker.

``` bash
#!/usr/bin/env python3
"""Session-start briefing: days since each movement pattern (muscle group / goal) was trained,
plus current working weights and next triggers. Everything is derived from program/current.yaml
- there is no separate tracker. A pattern's last-trained date is the most recent
current.last_session among the library entries tagged with it (written once, per exercise).
pattern_coverage (also in current.yaml) credits patterns that other session types train, so
EFFECTIVE staleness = the most recent of a pattern's direct date and any covering pattern's
date. Run: python3 scripts/staleness.py"""

from datetime import date, datetime
from pathlib import Path
import yaml

REPO = Path(__file__).resolve().parent.parent

def to_date(v):
    if v is None:
        return None
    if isinstance(v, date):
        return v
    return datetime.strptime(str(v), "%Y-%m-%d").date()

def main():
    today = date.today()
    program = yaml.safe_load((REPO / "program" / "current.yaml").read_text())
    coverage = program.get("pattern_coverage", {}) or {}

    # Group library entries by pattern; each pattern's direct date is the newest last_session
    # among its entries. The set of patterns is defined by the tags in the library itself.
    by_pattern, direct = {}, {}
    for kind in ("exercises", "cardio"):
        for ex in (program.get(kind) or {}).values():
            last = to_date((ex.get("current") or {}).get("last_session"))
            pats = ex.get("movement_pattern") or []
            for p in ([pats] if isinstance(pats, str) else pats):
                by_pattern.setdefault(p, []).append(ex)
                if last and (direct.get(p) is None or last > direct[p]):
                    direct[p] = last

    effective = {}
    for p in by_pattern:
        best, via = direct.get(p), None
        for coverer in coverage.get(p, {}).get("covered_by", []):
            cov = direct.get(coverer)
            if cov and (best is None or cov > best):
                best, via = cov, coverer
        effective[p] = (best, via)

    rows = sorted(by_pattern, key=lambda p: (effective[p][0] is not None, effective[p][0] or date.min))

    print(f"Movement pattern staleness as of {today} ({today.strftime('%A')})")
    print("(EFF credits pattern_coverage; '~' = partial coverage)\n")
    print(f"{'PATTERN':<20} {'DIRECT':<12} {'DAYS':>5}  {'EFF':>5}  COVERED BY")
    print("-" * 62)
    for p in rows:
        d = direct.get(p)
        eff_date, via = effective[p]
        days = "never" if d is None else str((today - d).days)
        eff_days = "never" if eff_date is None else str((today - eff_date).days)
        partial = "~" if via and coverage.get(p, {}).get("partial") else ""
        via_str = f"{partial}{via} ({eff_date})" if via else ""
        print(f"{p:<20} {str(d or '-'):<12} {days:>5}  {eff_days:>5}  {via_str}")

    print("\nCurrent state per pattern (stalest first):\n")
    for p in rows:
        eff_date, via = effective[p]
        header = "never trained" if eff_date is None else f"{(today - eff_date).days} days ago" + (f" via {via}" if via else "")
        print(f"## {p} ({header})")
        for ex in by_pattern[p]:
            cur = ex.get("current") or {}
            load = cur.get("weight") or cur.get("duration") or "?"
            role = f" [{ex['role']}]" if ex.get("role") else ""
            print(f"  {ex['name']} ({ex.get('variant', '')}){role}: {load}")
            trend = (cur.get("trend") or "").strip().replace("\n", " ")
            if trend:
                print(f"    trend: {trend}")
        print()

if __name__ == "__main__":
    main()
bash
#!/usr/bin/env python3
"""Validate session logs and program state against the canonical schema. The movement-pattern
vocabulary is defined by the tags on program/current.yaml library entries - there is no
separate tracker file to keep in sync. Logs dated before GRANDFATHER_DATE warn only (they
predate later schema changes); newer logs must pass. Exits nonzero on any error. Run in CI and
after writing a log."""

import re, sys
from datetime import date, datetime
from pathlib import Path
import yaml

REPO = Path(__file__).resolve().parent.parent

# --- CUSTOMIZE THESE for the user ---
GRANDFATHER_DATE = date(2000, 1, 1)   # set to the repo start date; bump when you migrate schema
VALID_TYPES = {"strength", "cardio", "yoga", "sport", "rest"}
# ------------------------------------

REQUIRED_FIELDS = ["date", "day_of_week", "type", "location"]
LEGACY_FIELDS = {  # names the model tends to drift into -> the canonical name to use instead
    "day": "day_of_week", "duration_min": "duration", "duration_estimate": "duration",
    "session_context": "notes", "session_notes": "notes", "how_user_felt": "notes",
}
PR_KEYS = {"exercise", "variant", "type", "detail"}
errors, warnings = [], []

def to_date(v):
    if isinstance(v, date):
        return v
    try:
        return datetime.strptime(str(v), "%Y-%m-%d").date()
    except (ValueError, TypeError):
        return None

def report(path, msg, log_date):
    (warnings if (log_date and log_date < GRANDFATHER_DATE) else errors).append(f"{path.relative_to(REPO)}: {msg}")

def load_program():
    return yaml.safe_load((REPO / "program" / "current.yaml").read_text())

def known_patterns(program):
    """The pattern vocabulary = every movement_pattern tag used by a library entry."""
    pats = set()
    for kind in ("exercises", "cardio"):
        for ex in (program.get(kind) or {}).values():
            p = ex.get("movement_pattern")
            for tag in (p if isinstance(p, list) else [p]):
                if tag:
                    pats.add(tag)
    return pats

def library_ids(program):
    ids = set()
    for kind in ("exercises", "cardio"):
        ids |= set((program.get(kind) or {}).keys())
    return ids

def check_log(path, patterns, lib):
    try:
        docs = list(yaml.safe_load_all(path.read_text()))
    except yaml.YAMLError as e:
        errors.append(f"{path.relative_to(REPO)}: YAML parse error: {e}")
        return
    m = re.match(r"(\d{4}-\d{2}-\d{2})", path.name)
    fname_date = to_date(m.group(1)) if m else None
    if len(docs) > 1:
        report(path, f"{len(docs)} YAML docs in one file - one session per file", fname_date)
    doc = docs[0] if docs else None
    if not isinstance(doc, dict):
        report(path, "log is not a YAML mapping", fname_date)
        return
    log_date = to_date(doc.get("date")) or fname_date
    for f in REQUIRED_FIELDS:
        if f not in doc:
            report(path, f"missing required field '{f}'", log_date)
    if fname_date and to_date(doc.get("date")) not in (None, fname_date):
        report(path, f"date {doc.get('date')} != filename date {fname_date}", log_date)
    if log_date and doc.get("day_of_week"):
        actual = log_date.strftime("%A")
        if str(doc["day_of_week"]) != actual:
            report(path, f"day_of_week '{doc['day_of_week']}' should be '{actual}'", log_date)
    for legacy, canon in LEGACY_FIELDS.items():
        if legacy in doc:
            report(path, f"legacy field '{legacy}' - use '{canon}'", log_date)
    stype = doc.get("type")
    if stype and stype not in VALID_TYPES:
        report(path, f"unknown type '{stype}' (expected {sorted(VALID_TYPES)})", log_date)
    if stype == "strength":
        exs = doc.get("exercises")
        if not isinstance(exs, list) or not exs:
            report(path, "strength session needs a non-empty 'exercises' list", log_date)
        else:
            for i, ex in enumerate(exs):
                lbl = f"exercises[{i}]"
                if not isinstance(ex, dict):
                    report(path, f"{lbl} is not a mapping", log_date); continue
                for f in ("name", "variant", "movement_pattern"):
                    if not ex.get(f):
                        report(path, f"{lbl} ({ex.get('name','?')}) missing '{f}'", log_date)
                if "exercise_id" not in ex:
                    report(path, f"{lbl} missing 'exercise_id' (a program key, or null for a one-off)", log_date)
                elif ex["exercise_id"] is not None and ex["exercise_id"] not in lib:
                    report(path, f"{lbl} exercise_id '{ex['exercise_id']}' not a program/current.yaml key", log_date)
                pat = ex.get("movement_pattern")
                for p in (pat if isinstance(pat, list) else [pat]):
                    if p and p not in patterns:
                        report(path, f"{lbl} movement_pattern '{p}' is not tagged by any library entry", log_date)
                if "sets" not in ex:
                    report(path, f"{lbl} ({ex.get('name','?')}) missing 'sets'", log_date)
    elif stype in VALID_TYPES and not doc.get("notes") and not doc.get("exercises"):
        report(path, f"{stype} session needs a narrative 'notes' block", log_date)
    prs = doc.get("prs")
    if prs is not None:
        if not isinstance(prs, list):
            report(path, "'prs' must be a list of objects", log_date)
        else:
            for i, pr in enumerate(prs):
                if not isinstance(pr, dict):
                    report(path, f"prs[{i}] must be an object", log_date)
                elif not PR_KEYS.issubset(pr):
                    report(path, f"prs[{i}] missing keys: {sorted(PR_KEYS - set(pr))}", log_date)

def check_program(program, patterns):
    # last_session values must parse, and pattern_coverage must reference real patterns.
    for kind in ("exercises", "cardio"):
        for eid, ex in (program.get(kind) or {}).items():
            ls = (ex.get("current") or {}).get("last_session")
            if ls is not None and to_date(ls) is None:
                errors.append(f"program/current.yaml: {kind}.{eid} current.last_session invalid date '{ls}'")
    for k, cov in (program.get("pattern_coverage") or {}).items():
        if k not in patterns:
            errors.append(f"program/current.yaml: pattern_coverage key '{k}' has no library entry that trains it")
        for c in (cov or {}).get("covered_by", []):
            if c not in patterns:
                errors.append(f"program/current.yaml: pattern_coverage.{k} covered_by '{c}' has no library entry")

def check_prs_file(lib):
    data = yaml.safe_load((REPO / "logs" / "prs.yaml").read_text()) or {}
    for key in (data.get("records") or {}):
        if key not in lib and not str(key).startswith("legacy_"):
            errors.append(f"logs/prs.yaml: record key '{key}' not a program id or legacy_* key")

def main():
    program = load_program()
    patterns, lib = known_patterns(program), library_ids(program)
    logs = sorted((REPO / "logs").glob("*.yaml"))
    skip = {"TEMPLATE.yaml", "prs.yaml"}
    for p in logs:
        if p.name not in skip:
            check_log(p, patterns, lib)
    check_program(program, patterns)
    check_prs_file(lib)
    for w in warnings:
        print(f"WARN  {w}")
    for e in errors:
        print(f"ERROR {e}")
    print(f"\n{len(logs) - len(skip)} logs checked: {len(errors)} error(s), {len(warnings)} warning(s)")
    sys.exit(1 if errors else 0)

if __name__ == "__main__":
    main()
```

-
**The model will silently corrupt its own conventions.** Across a long-running repo, an LLM drifts: the same field becomes`day`

then`day_of_week`

; durations become`duration_min`

/`duration_estimate`

; PRs become bare strings. Each choice looks fine in isolation; the aggregate rots the store you depend on.**You cannot fix this with willpower**— the next session's agent doesn't inherit this one's resolve. Fix it with*external*structure: the documented schema (TEMPLATE.yaml), the validator, and CI on every push. The`LEGACY_FIELDS`

map in validate.py exists specifically to catch this drift. -
**Write each fact exactly once.** Two homes: full narrative → the session log; 2-sentence state + next trigger →`current.trend`

. Recency isn't a third home — it's*derived*from`current.last_session`

, which is why there's no separate tracker file to hand-maintain. Duplicating a paragraph across files means future updates skew and the working memory bloats; if you're pasting, point to the log instead. (This is exactly why`recent.yaml`

was removed: it re-stored last-trained dates that already live in`current.yaml`

.) -
**Never hand-compute staleness.** Run`staleness.py`

; trust it. An LLM eyeballing "days since" will produce confident wrong numbers. Don't write "17 days stale" into any file — it rots; compute it on demand. -
**Stable** The`exercise_id`

vs free-text`variant`

.`exercise_id`

(a library key) is the*matching key*— PR and history tracking key off it, so it must be exact. The`variant`

is free text for how it was done today. This split lets you swap equipment (a dumbbell bench for a machine press) or migrate implements without breaking history or looking like a regression. Same movement, changed conditions → same id, new variant, note it. A genuinely new movement gets its own id. -
**Units are the disambiguator; always carry them.**`2x30`

is ambiguous — two 30 lb dumbbells, or 2 sets of 30? Rule:**weights always carry a unit**(`2×30 lb`

= two 30s),**sets×reps never do**(`3×8`

= 3 sets of 8). So`3×8 @ 2×30 lb`

is unambiguous. Put this in CLAUDE.md. -
**Timezone the log filenames.** The system clock may be UTC; the user isn't. Name log files and set`date:`

in the user's timezone, and look up`day_of_week`

with`TZ="<their/tz>" date -d 'YYYY-MM-DD' '+%A'`

rather than guessing (the validator checks that the day matches the date). -
**Confirm dictated numbers back.** Voice logging is great but speech-to-text garbles numbers ("fifty" vs "fifteen", "45 for 3 sets of 8"). Read the numbers back before writing. -
**Match the structure level they chose — and let it evolve.** Don't impose a rigid weekly template on someone with a chaotic schedule, and don't leave a beginner who wants to be told what to do staring at an empty library. Whatever you pick isn't permanent — revisit it as their schedule and adherence show what actually fits. -
**Effective staleness / coverage stops the coach nagging.** If a recurring activity trains a goal (a weekly class covers`core`

; a sport banks interval cardio), record it in`pattern_coverage`

so the goal doesn't read as "stale" when it's actually being trained indirectly. Re-examine coverage when the user's routine changes (e.g. a sport goes off-season — its coverage should stop counting). -
**Sensor data is directional, not gospel.** Wearable HR zones are formula-estimated and can be skewed by meds, heat, stress, caffeine. Log the numbers for the record, but pace by feel (talk test for easy work, RPE for hard) and trust the user over the watch when they disagree. Don't build progression off a single HR reading. -
**Grandfather your schema migrations.** When you change the schema mid-life, don't retro- break every old log. Set`GRANDFATHER_DATE`

so pre-migration logs*warn*and post-migration logs*error*. Migrate the back-catalog deliberately in its own commit, not all at once under pressure.

Before you hand the repo over, verify:

- Interview summary was played back and confirmed by the user.
- The exercise library was built from the interview, not kept as the doc's example list,
and every
`movement_pattern`

tag reflects a pattern the user will actually train. - Every
`exercise_id`

you'll log against exists as a key in`program/current.yaml`

. - Every
`pattern_coverage`

key and`covered_by`

entry is a pattern some library entry tags. -
`VALID_TYPES`

in`validate.py`

matches the session types from the interview (§1 C). - Only the scheduling block for their chosen structure level remains in the program.
-
`pip install pyyaml && python3 scripts/staleness.py`

runs and prints a sane briefing (everything "never trained" on day one is expected). -
`python3 scripts/validate.py`

exits 0 (no logs yet — it's confirming`current.yaml`

and`prs.yaml`

are internally consistent). - CLAUDE.md covers everything in §2.6.
-
`git init`

, first commit, and (if using GitHub) the CI workflow is present so the validator runs on push. - Tell the user how to start a session:
*open an agent in the repo and say "I'm training at*`<location>`

today, I've got`<time>`

, feeling`<energy>`

— what should I do?"

That's it. From here on, the repo is the memory and each session is: brief → compose → confirm → train → log → validate → commit. The coach forgets; the files remember.
