| 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
strengthandcardio, then add every named activity they do (yoga, a specific sport, a class, a swim). This answer becomes the logtypeset and the validator'sVALID_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 lossysport.
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 skipname: "<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
SCHEDULINGis shared. If they're unsure, use Scaffolded — 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, andpattern_coveragelives in this same file — so a session only ever updatescurrent.yaml(andprs.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 withTZ="<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.pyfirst (never hand-compute staleness). Read the relevantequipment/profile andprogram/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'scurrentinprogram/current.yaml(weight/duration,last_session= today, 2-sentence trend; bumptargetif the trigger was met). This is the only recency bookkeeping — the staleness script readslast_session, so there is no separate tracker to update. (c) Updatelogs/prs.yamlfor any PRs. (d) Runpython3 scripts/validate.pyand fix errors. (e) Commit; push; open a PR titledWorkout: <focus> - <date>with a summary. - The write-once rule. Full narrative → the log. 2-sentence state + next trigger →
current.trend. Recency is derived fromcurrent.last_session, not written anywhere separately. Never paste the same paragraph into two files. - Notation standards — restate the units and
exercise_id/variantrules 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.pyBoth 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.
#!/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()#!/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
daythenday_of_week; durations becomeduration_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. TheLEGACY_FIELDSmap 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 fromcurrent.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 whyrecent.yamlwas removed: it re-stored last-trained dates that already live incurrent.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
exercise_idvs free-textvariant. Theexercise_id(a library key) is the matching key — PR and history tracking key off it, so it must be exact. Thevariantis 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.
2x30is 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). So3×8 @ 2×30 lbis 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 upday_of_weekwithTZ="<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 inpattern_coverageso 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_DATEso 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_patterntag reflects a pattern the user will actually train. - Every
exercise_idyou'll log against exists as a key inprogram/current.yaml. - Every
pattern_coveragekey andcovered_byentry is a pattern some library entry tags. -
VALID_TYPESinvalidate.pymatches 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.pyruns and prints a sane briefing (everything "never trained" on day one is expected). -
python3 scripts/validate.pyexits 0 (no logs yet — it's confirmingcurrent.yamlandprs.yamlare 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.