From 51985bed96d486d24c40e51e422db2d303f56765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 16:40:39 +0200 Subject: [PATCH 01/22] Add living evidence + weekly follow-up design spec Co-Authored-By: Claude Fable 5 --- ...17-evidence-loop-weekly-followup-design.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-evidence-loop-weekly-followup-design.md diff --git a/docs/superpowers/specs/2026-07-17-evidence-loop-weekly-followup-design.md b/docs/superpowers/specs/2026-07-17-evidence-loop-weekly-followup-design.md new file mode 100644 index 0000000..cb9d43a --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-evidence-loop-weekly-followup-design.md @@ -0,0 +1,363 @@ +# Living Evidence & Weekly Follow-up — Design + +Date: 2026-07-17 +Status: approved (design review with owner) +Target release: v0.8.0 + +## 1. Overview + +Four features that turn the research pipeline from an intake ritual into a +living loop, and give the coach a weekly follow-up rhythm: + +- **A. Athlete documentation folder** — a `documentation/` directory in the + athlete's data folder where they drop documents (published studies, physio + reports, lab results, past programs). The agent detects new files + automatically and routes each into one of two lanes. +- **B. Research during the program** — targeted "mini-waves" of the + deep-research protocol, triggered by adaptation events, mesocycle + boundaries, and dropped documents. Skills-only; no new server code. +- **C. Science in the HTML deliverable** — the offline program HTML gains an + advice banner (nutrition/supplements), a "why this program" section, `[n]` + citation markers on exercise blocks, and a starred bibliography. +- **D. Weekly follow-up** — a `next-week-loads` skill backed by a + deterministic engine tool that computes next week's loads from the logged + week, and a `program-watch` skill that audits the running program per + exercise and routes substitution candidates to `program-adaptation`. + +Explicitly excluded by owner decision: any photo/video check-in (privacy). +Supplement guidance is advice, never prescription. + +## 2. Decisions taken with the owner + +| Question | Decision | +|---|---| +| What science shows in the HTML | Vulgarized header (advice + rationale) + `[n]` markers on blocks + starred bibliography in footer | +| Status of dropped documents | Two lanes: verified locator → evidence corpus; everything else → athlete context, never cited as science | +| When documents are read | Automatic: deep-research always scans first; coach/check-in entry points detect new files | +| Documents architecture | Dedicated MCP tools for inventory + registry; the client reads files (native PDF reading) | +| Research during the program | In scope here (owner priority), via mini-waves | +| Weekly loads + program audit | Two separate skills, each with its own cadence | +| Next-week load computation | Structured progression rule on blocks + deterministic engine tool | + +## 3. The athlete documentation folder + +### 3.1 Creation + +`documentation/` lives in the athlete data directory, next to `research/` and +`programs/`. It is created (with a short bilingual FR/EN `README.md` +explaining what to drop and what happens to it) by two paths: + +- `write_profile` — so onboarding creates it; +- the first call to `list_athlete_documents` — so existing athletes get it + without re-onboarding. + +`resolve_athlete_dir()` stays pure (never creates anything). + +### 3.2 The registry + +`documentation/index.yaml`, validated by a Pydantic `DocumentRecord` model, +written atomically (same pattern as `personal_corpus`). One record per +processed file: + +```yaml +documents: + - filename: "creatine-meta.pdf" + sha256: "…" + size_bytes: 812345 + first_seen: "2026-07-17" + processed_on: "2026-07-18" + status: processed # processed | unreadable + lane: evidence # evidence | context | null (unreadable) + summary: "Meta-analysis, creatine 3-5 g/day, strength outcomes." + key_points: ["…"] + evidence_ids: ["creatine-kreider-2017"] +``` + +Only `processed` and `unreadable` are stored. `new`, `modified` and `removed` +are **derived at scan time** (file present but not in registry; hash differs +from registry; registry entry without file). The registry file itself and +`README.md` are excluded from scans. A deleted or corrupt registry is +rebuilt empty: files simply show up as `new` again; re-processing an +already-saved study fails cleanly on corpus id uniqueness. + +### 3.3 New MCP tools + +**`list_athlete_documents()`** — creates folder + README if missing, scans, +compares hashes against the registry, returns +`{path, new: […], modified: […], processed: […], removed: […], unreadable: […]}` +where each item carries absolute path, size and (for processed) the stored +summary — so the agent knows what it already knows without re-reading. +Never writes the registry. + +**`mark_document_processed(filename, lane, summary?, key_points?, evidence_ids?)`** +— records the outcome. Validations: file must exist in `documentation/`, +`lane ∈ {evidence, context, unreadable}`, `summary` required unless +unreadable, every `evidence_id` must exist in the loaded corpus (no phantom +traceability). + +Reading the documents themselves is the **client's** job (Claude reads PDFs +natively, better than any server-side extraction). The server hands out +paths, never file content. + +### 3.4 The two lanes + +Hard rule, preserving the zero-fabricated-citation guarantee: + +- **Evidence lane** — only if a DOI/PMID/ISBN found in the document + **resolves via `verify_reference`**. Then the existing pipeline applies + unchanged: `save_evidence` re-verifies locator + title match. Bonus: the + agent read the full text, so saved `conclusions` can be richer than the + abstract-only live-search entries. +- **Context lane** — everything else (physio reports, lab results, past + programs, blog articles, unverifiable PDFs). Summary + key points persist + in the registry and inform personalization (needs-analysis, planning, + adaptation), but are **never rendered as scientific citations** in any + deliverable. + +### 3.5 Skill integration + +- `deep-research` — new mandatory step 0: process the documentation folder + before any online search; dropped documents shape the facets. +- `performance-coach` and `training-checkin` — call `list_athlete_documents` + in their opening ritual (alongside `list_due_actions`) and handle + new/modified files. +- `athlete-onboarding` — tells the athlete the folder exists and what to + drop in it. + +## 4. Research during the program — mini-waves + +No new server code: `search_evidence_live` filters (`year_from`, +`publication_types`) and the immutable, reason-carrying dossier versioning +already support this. Skills change only. + +### 4.1 The mini-wave + +A reduced deep-research pass scoped to **one question**: corpus first, then +2-3 live queries in English + the athlete's locale (+1 language if the facet +is thin), same verification and save rules, result folded into the dossier +as **v+1 with `reason` = the trigger** and a "what changed vs v{N}" section. +Minutes, not the full multi-facet protocol. + +### 4.2 Triggers + +1. **Substantive adaptation trigger** (confirmed plateau, recurring pain, + calendar or method change). Urgency rule: **adapt first, research + second** — tonight's session never waits for literature; the mini-wave + informs the program's next version, not the immediate fix. + `program-adaptation` gains `read_research_dossier` and + `save_research_dossier` in its tool list plus the mini-wave protocol. +2. **Mesocycle boundary — incremental watch**: replay the dossier facets' + queries with `year_from` = the current dossier's year; thin facets first. + Something new → dossier v+1; nothing → no new version. Carried by + `training-checkin` (it sees calendar and program). +3. **A drop or a question**: a new document touching a program facet + (§3.5), or an explicit athlete question ("I read that…"). + +### 4.3 Safety loop + +A mini-wave that contradicts the active program routes to +`program-adaptation`, which proposes the sourced change to the athlete — +never a silent program edit. Quality gate unchanged: `program-review` and +the citation locks at every delivery. + +## 5. Weekly follow-up + +### 5.1 Structured progression rules + +`ExerciseBlock` gains an optional `progression: ProgressionRule | None`. +The existing free-text `progression_rule` stays (human display, old +programs); when the structured rule is present it is the **source of +computation** and the text is its rendering (written by +`program-optimization`, coherence checked by `program-review`). + +```python +class ProgressionRule(BaseModel): + kind: Literal["double", "linear_load", "rir_target", "from_pct", "none"] + rep_min: int | None = None # double + rep_max: int | None = None # double + increment_kg: float | None = None # double, linear_load + target_rir: float | None = None # rir_target + adjust_pct_per_rir: float = 0.03 # rir_target + rounding_kg: float = 2.5 +``` + +Per-kind validators enforce required params. Semantics: + +- `double` — all logged sets reached `rep_max` → next = load + + `increment_kg` (aim back at `rep_min`); otherwise same load, aim for more + reps. +- `linear_load` — all sets hit prescribed reps → +`increment_kg`; any + failed set → hold (no auto-deload in v1; repeated failure is + `program-watch`'s signal). +- `rir_target` — next = load × (1 + `adjust_pct_per_rir` × (mean logged RIR + − `target_rir`)), rounded to `rounding_kg`. +- `from_pct` — for blocks prescribed in `pct_1rm` (waves, top-set plans): + e1RM = best logged set of that exercise in the last 14 days + (engine Epley), falling back to `lift_inventory`; next = next week's + planned pct × e1RM. +- `none` — no numeric suggestion ("per plan"): endurance pace blocks, + technique work. + +### 5.2 Engine tool `suggest_next_week_loads()` + +Deterministic, zero LLM in the loop. Reads the active program and the +logged sessions (window `days_back`, default 7) from the store, matches +logs to program blocks by exercise identity (`exercise_id`, falling back to +name). The program week whose sessions match the most logged sessions in +the window is the current week (`week_matched`); suggestions target each +matched block's **next occurrence** — the same block when the program +repeats its week, the following week's block (e.g. its planned pct for +`from_pct`) when weeks differ. Applies each block's rule and returns in +one call: + +```json +{ + "week_matched": 3, + "blocks": [{ + "session": "A", "exercise": "Bench press", "rule_kind": "double", + "prescribed": {"sets": 4, "reps": "8-12", "load_kg": 80}, + "actual": {"sets_completed": 4, "reps": [12,12,12,12], "mean_rir": 1.5}, + "next": {"load_kg": 82.5}, + "rationale": "double progression: top of range on all sets — +2.5 kg", + "flags": [] + }], + "unmatched_logs": ["Face pull (not in program)"], + "flags": ["no_rule: Nordic curl"] +} +``` + +Degraded cases are flags, never guesses: `no_rule` (unstructured block — +the skill handles it manually), `unmatched` (block with no logged session), +`failed_sets` (→ hold). A successful run records its date in a one-line +state file so diligence can see the review happened. + +### 5.3 Skill `next-week-loads` + +Thin. Triggered at the end of a training week (routed by +`performance-coach`; new diligence action "training week finished without a +loads review", read from the state file). Calls the tool, presents the +table, discusses flagged blocks with the athlete, answers "why" from the +rationale. **Versions nothing** — it concretizes next week, it does not +modify the program. + +### 5.4 Skill `program-watch` + +The running program's auditor. Data only, **per exercise**: e1RM trajectory +over the mesocycle (endurance: pace/HR via `compare_prescribed_actual`), +per-exercise adherence (a systematically skipped or cut-short movement is a +signal), recurring pain linked to a movement (`pain_flags` + session +notes), chronic prescribed-vs-actual gaps, load monotony per pattern. + +Verdict per exercise: **keep / watch / substitution candidate** (reason, +replacements proposed via `score_exercises`, citation when one exists). +It **never edits anything**: substitution candidates route to +`program-adaptation` (diagnosis, versioning, `program-review` gate all +unchanged). + +Cadence: every 2 weeks + at each mesocycle boundary (where it pairs with +the incremental watch of §4.2: watch says *what to watch*, the mini-wave +says *what the science says*) + on demand. Designed to run as a +**subagent** launched by `performance-coach`/`training-checkin`, returning +a short report. + +### 5.5 `save_watch_report` + diligence + +`save_watch_report(markdown_body)` persists the report as a versioned doc +(`watch/report-vN.md`, generic `_save_versioned_doc` helper). This gives an +audit history and a timestamp diligence can read. New diligence actions: + +- `weekly_loads_review_due` — logged sessions this week and no review + recorded for ≥6 days. +- `program_watch_due` — active program and no watch report for ≥14 days. + +## 6. Science in the HTML deliverable + +### 6.1 Model + +`ProgramPlan` gains two optional lists: + +```python +class Guidance(BaseModel): + text: str # 1..300 chars + cite: str | None # corpus id, same semantics as ExerciseBlock.cite + +advice: list[Guidance] = [] # nutrition / supplement / recovery advice +rationale: list[Guidance] = [] # "why this program" key messages +``` + +Filled by `program-optimization` (supplement content sourced from the +dossier/corpus; `nutrition-planning` provides the frame). Honesty rule +identical to blocks: no corpus backing → phrased as coaching judgment, +never a fake cite. `program-review` checks these sections like the rest. + +### 6.2 Rendering + +- **HTML** (`render_html.py`): header banner (💊 advice, then 🔬 rationale, + per the validated mockup), `[n]` markers on exercise blocks from the + existing `cite` field, starred bibliography in the footer + (`format_citation` + `STARS`, DOI as a clickable `https://doi.org/…` + link — inert offline, breaks nothing). Numbering by order of first + appearance: advice → rationale → blocks in program order. Section titles + localized via the existing `_t` mechanism. +- **Markdown** (`render.py`): renders the same two sections and a final + "Sources" section with full citations (DOI/PMID in clear), which also + makes the Typst PDF's expert-mode bibliography pick them up unchanged. +- **PDF**: no renderer change needed beyond what flows in from the + markdown body. + +## 7. Error handling & edge cases + +- Unreadable/corrupt dropped file → `lane: unreadable`; only revisited if + its hash changes. +- Deleted file → reported `removed` at scan; its corpus entries stay (they + are independently verified). +- Registry deleted/corrupt → rebuilt; duplicate corpus ids fail cleanly. +- Large files → size returned by the scan; the agent paginates its read. +- `mark_document_processed` with unknown filename or corpus-missing + `evidence_id` → `ValueError` with the offending value. +- `suggest_next_week_loads` with no active program or an all-`none` week → + explicit empty result with reason, not an error. +- Week with partial logs → per-block `unmatched` flags; computation + proceeds for matched blocks. +- Old programs without structured rules → every block flagged `no_rule`; + the skill falls back to conversational handling (v1 behavior for legacy + programs, no migration). +- Mini-wave finding that contradicts the active program → routed proposal, + never a silent edit (§4.3). + +## 8. Testing + +All offline, no network, tmp-dir athlete folders: + +- Registry: scan states (new/modified/processed/removed/unreadable), + corrupt registry rebuild, atomic write, README creation paths + (`write_profile` and first `list`). +- `mark_document_processed`: lane validation, evidence-id existence check, + unreadable path without summary. +- Engine: each `ProgressionRule` kind × (achieved / failed / partial logs / + unmatched / no rule); rounding; e1RM fallback to `lift_inventory`; + `from_pct` next-week resolution; state-file write. +- Schemas: `ProgressionRule` per-kind validators, `Guidance` cite/text + bounds, `ProgramPlan` backward compat (old docs without new fields load). +- Rendering: HTML with/without advice/rationale/cites (snapshot), + numbering order, bibliography formatting, markdown Sources section. +- Diligence: both new actions fire and clear on their conditions. + +## 9. Out of scope + +- Pre-competition spec (peak week, race week/day) — separate design. +- Server-side PDF text extraction — the client reads files. +- Any push mechanism — diligence stays pull-based. +- Auto-deload on repeated failure — `program-watch` surfaces it, + `program-adaptation` decides. + +## 10. Tally + +- Tools: 93 → **97** (`list_athlete_documents`, `mark_document_processed`, + `suggest_next_week_loads`, `save_watch_report`). +- Skills: 12 → **14** (`next-week-loads`, `program-watch`), plus edits to + `deep-research`, `program-adaptation`, `training-checkin`, + `performance-coach`, `athlete-onboarding`, `program-optimization`, + `program-review`. +- Schemas: `DocumentRecord`, `ProgressionRule`, `Guidance`; `ExerciseBlock` + and `ProgramPlan` gain optional fields (backward compatible). From 16a2c98da3a75910695d3b04ff604a78b2cb9292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 16:59:01 +0200 Subject: [PATCH 02/22] Add living evidence + weekly follow-up implementation plan Co-Authored-By: Claude Fable 5 --- ...026-07-17-evidence-loop-weekly-followup.md | 3019 +++++++++++++++++ 1 file changed, 3019 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md diff --git a/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md b/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md new file mode 100644 index 0000000..374fd40 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md @@ -0,0 +1,3019 @@ +# Living Evidence & Weekly Follow-up Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the four features of `docs/superpowers/specs/2026-07-17-evidence-loop-weekly-followup-design.md`: the athlete `documentation/` folder with two lanes, science in the HTML deliverable, the deterministic weekly loads review, the program-watch audit skill, and the research mini-waves (skills-only). + +**Architecture:** Engine stays pure (new `engine/progression.py`), file I/O lives in `memory/` (new `documents.py`, `weekly_review.py`, store additions), MCP wrappers in `server/` (two new modules), rendering in `programs/`. Citation resolution happens server-side so the store keeps zero evidence dependencies. Skills change last, with `tests/skills` invariants updated in the same task. + +**Tech Stack:** Python 3.13, uv, pydantic v2, FastMCP, pytest. Run everything with `uv run`. + +**Branch:** work continues on `evidence-loop-weekly-followup-spec` (spec already committed). Version bump/release is NOT part of this plan (single release at the end, separately). + +--- + +## File structure + +| File | Status | Responsibility | +|---|---|---| +| `src/performance_agent/memory/documents.py` | create | Documentation folder: README, registry schema, scan, mark | +| `src/performance_agent/server/document_tools.py` | create | MCP tools `list_athlete_documents`, `mark_document_processed` | +| `src/performance_agent/engine/progression.py` | create | Pure next-load math for each ProgressionRule kind | +| `src/performance_agent/memory/weekly_review.py` | create | Read program+logs, match week, dispatch rules, state file | +| `src/performance_agent/server/followup_tools.py` | create | MCP tools `suggest_next_week_loads`, `save_watch_report` | +| `src/performance_agent/memory/schemas.py` | modify | `ProgressionRule`, `ExerciseBlock.progression`, `Guidance`, `ProgramPlan.advice/rationale` | +| `src/performance_agent/memory/store.py` | modify | `write_profile` hook, watch report versioned doc, citations param on `save_program` | +| `src/performance_agent/engine/diligence.py` | modify | Facts + `loads_review` / `program_watch` due actions | +| `src/performance_agent/memory/diligence.py` | modify | Extract the new facts from files | +| `src/performance_agent/evidence/citations.py` | modify | `ResolvedCitation`, `resolve_citations` | +| `src/performance_agent/programs/render.py` | modify | `plan_citation_ids`, advice/rationale/Sources sections | +| `src/performance_agent/programs/render_html.py` | modify | Banner, `[n]` markers, bibliography, labels, CSS | +| `src/performance_agent/server/memory_tools.py` | modify | Wire citations into `save_program` + `_write_program_html` | +| `src/performance_agent/server/app.py` | modify | Register the two new tool modules | +| `skills/next-week-loads/SKILL.md` | create | Weekly loads review ritual | +| `skills/program-watch/SKILL.md` | create | Per-exercise program audit | +| 7 existing `skills/*/SKILL.md` | modify | Documents step 0, mini-waves, routing, structured progression | +| `tests/skills/test_structure.py` | modify | EXPECTED_SKILLS + 2 protocol tests | +| `README.md` | modify | Tool count and feature bullets | + +--- + +## Phase 1 — Athlete documentation folder + +### Task 1: `memory/documents.py` — registry schema, scan, mark + +**Files:** +- Create: `src/performance_agent/memory/documents.py` +- Test: `tests/memory/test_documents.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/memory/test_documents.py`: + +```python +"""Documentation folder: registry, scan states, mark validation.""" + +from datetime import date + +import pytest + +from performance_agent.memory.documents import ( + DOCUMENTATION_DIR, + README_FILE, + REGISTRY_FILE, + ensure_documentation_dir, + load_registry, + mark_processed, + scan_documents, +) + +TODAY = date(2026, 7, 17) + + +def _drop(base, name, content=b"pdf-bytes"): + doc_dir = base / DOCUMENTATION_DIR + doc_dir.mkdir(parents=True, exist_ok=True) + (doc_dir / name).write_bytes(content) + + +def test_ensure_creates_folder_and_readme(tmp_path): + path = ensure_documentation_dir(tmp_path) + assert path == tmp_path / DOCUMENTATION_DIR + assert path.is_dir() + readme = path / README_FILE + assert readme.exists() + assert "documentation" in readme.read_text(encoding="utf-8").casefold() + + +def test_ensure_is_idempotent_and_keeps_readme_edits(tmp_path): + readme = ensure_documentation_dir(tmp_path) / README_FILE + readme.write_text("custom", encoding="utf-8") + ensure_documentation_dir(tmp_path) + assert readme.read_text(encoding="utf-8") == "custom" + + +def test_scan_reports_new_files_and_excludes_registry_and_readme(tmp_path): + ensure_documentation_dir(tmp_path) + _drop(tmp_path, "study.pdf") + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["new"]] == ["study.pdf"] + assert result["modified"] == [] + assert result["processed"] == [] + assert result["removed"] == [] + assert result["unreadable"] == [] + + +def test_mark_then_scan_reports_processed_with_summary(tmp_path): + _drop(tmp_path, "study.pdf") + record = mark_processed( + tmp_path, + "study.pdf", + lane="evidence", + summary="Creatine meta-analysis.", + evidence_ids=["creatine-2017"], + known_evidence_ids={"creatine-2017"}, + today=TODAY, + ) + assert record.lane == "evidence" + result = scan_documents(tmp_path) + assert result["new"] == [] + assert result["processed"][0]["summary"] == "Creatine meta-analysis." + + +def test_modified_file_is_reported_for_reprocessing(tmp_path): + _drop(tmp_path, "study.pdf") + mark_processed( + tmp_path, "study.pdf", lane="context", summary="v1", + known_evidence_ids=set(), today=TODAY, + ) + _drop(tmp_path, "study.pdf", content=b"changed-bytes") + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["modified"]] == ["study.pdf"] + + +def test_removed_is_derived_not_stored(tmp_path): + _drop(tmp_path, "study.pdf") + mark_processed( + tmp_path, "study.pdf", lane="context", summary="s", + known_evidence_ids=set(), today=TODAY, + ) + (tmp_path / DOCUMENTATION_DIR / "study.pdf").unlink() + result = scan_documents(tmp_path) + assert result["removed"] == ["study.pdf"] + stored = load_registry(tmp_path) + assert [r.filename for r in stored.documents] == ["study.pdf"] + + +def test_unreadable_lane_needs_no_summary(tmp_path): + _drop(tmp_path, "corrupt.pdf") + record = mark_processed( + tmp_path, "corrupt.pdf", lane="unreadable", + known_evidence_ids=set(), today=TODAY, + ) + assert record.summary is None + assert scan_documents(tmp_path)["unreadable"][0]["filename"] == "corrupt.pdf" + + +def test_mark_unknown_file_fails(tmp_path): + ensure_documentation_dir(tmp_path) + with pytest.raises(ValueError, match="ghost.pdf"): + mark_processed( + tmp_path, "ghost.pdf", lane="context", summary="s", + known_evidence_ids=set(), today=TODAY, + ) + + +def test_mark_evidence_or_context_requires_summary(tmp_path): + _drop(tmp_path, "study.pdf") + with pytest.raises(ValueError, match="summary"): + mark_processed( + tmp_path, "study.pdf", lane="evidence", + known_evidence_ids=set(), today=TODAY, + ) + + +def test_mark_rejects_unknown_evidence_id(tmp_path): + _drop(tmp_path, "study.pdf") + with pytest.raises(ValueError, match="phantom-id"): + mark_processed( + tmp_path, "study.pdf", lane="evidence", summary="s", + evidence_ids=["phantom-id"], known_evidence_ids={"other"}, today=TODAY, + ) + + +def test_corrupt_registry_is_rebuilt_empty(tmp_path): + _drop(tmp_path, "study.pdf") + (tmp_path / DOCUMENTATION_DIR / REGISTRY_FILE).write_text( + "not: [valid", encoding="utf-8" + ) + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["new"]] == ["study.pdf"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/memory/test_documents.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'performance_agent.memory.documents'` + +- [ ] **Step 3: Write the implementation** + +Create `src/performance_agent/memory/documents.py`: + +```python +"""Athlete-dropped documents: folder bootstrap, registry, scan, mark. + +The athlete drops files (studies, physio reports, past programs) into +documentation/; the agent detects new/changed files by content hash and records +what it did with each one. Only `processed` and `unreadable` are stored — +`new`, `modified` and `removed` are derived at scan time. The registry is +reconstructible by design: a deleted or corrupt index.yaml simply makes files +show up as new again (corpus entries are verified independently and survive). +""" + +import hashlib +import os +from datetime import date +from pathlib import Path +from typing import Literal, Self, TypedDict + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +DOCUMENTATION_DIR = "documentation" +REGISTRY_FILE = "index.yaml" +README_FILE = "README.md" +_EXCLUDED_FILES = {REGISTRY_FILE, README_FILE} + +_README_CONTENT = """\ +# Documentation + +EN — Drop documents for your coach here: published studies (PDF), physio or +medical reports you want considered, lab test results, past training programs. +New and changed files are picked up automatically. A study whose DOI/PMID can +be verified joins the evidence corpus; everything else informs your coaching +as context but is never presented as science. + +FR — Déposez ici les documents pour votre coach : études publiées (PDF), +bilans kiné/médicaux à partager, résultats de tests, anciens programmes. +Les fichiers nouveaux ou modifiés sont détectés automatiquement. Une étude +dont le DOI/PMID est vérifiable rejoint le corpus scientifique ; tout le +reste nourrit le coaching comme contexte, jamais présenté comme de la science. +""" + +Lane = Literal["evidence", "context", "unreadable"] + + +class DocumentRecord(BaseModel): + """One processed (or unreadable) dropped file, keyed by filename.""" + + model_config = ConfigDict(extra="forbid") + + filename: str = Field(min_length=1) + sha256: str = Field(min_length=64, max_length=64) + size_bytes: int = Field(ge=0) + first_seen: date + processed_on: date + lane: Lane + summary: str | None = None + key_points: list[str] = Field(default_factory=list) + evidence_ids: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _summary_required_unless_unreadable(self) -> Self: + if self.lane != "unreadable" and not self.summary: + msg = f"{self.filename}: a summary is required for lane {self.lane!r}" + raise ValueError(msg) + return self + + +class DocumentRegistry(BaseModel): + """The whole documentation/index.yaml file.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] = 1 + documents: list[DocumentRecord] = Field(default_factory=list) + + +class DocumentView(TypedDict): + """A file awaiting processing (new or modified).""" + + filename: str + path: str + size_bytes: int + + +class ProcessedView(TypedDict): + """A file the agent already handled, with what it retained.""" + + filename: str + path: str + lane: str + summary: str | None + + +class ScanResult(TypedDict): + """Derived folder state: only processed/unreadable are stored on disk.""" + + path: str + new: list[DocumentView] + modified: list[DocumentView] + processed: list[ProcessedView] + removed: list[str] + unreadable: list[ProcessedView] + + +def documentation_dir(base_dir: Path) -> Path: + """Return the documentation folder path (never creates it).""" + return base_dir / DOCUMENTATION_DIR + + +def ensure_documentation_dir(base_dir: Path) -> Path: + """Create the folder and its README when missing; never overwrites.""" + doc_dir = documentation_dir(base_dir) + doc_dir.mkdir(parents=True, exist_ok=True) + readme = doc_dir / README_FILE + if not readme.exists(): + readme.write_text(_README_CONTENT, encoding="utf-8") + return doc_dir + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + except OSError: + tmp.unlink(missing_ok=True) + raise + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_registry(base_dir: Path) -> DocumentRegistry: + """Load the registry; a missing or corrupt file yields an empty registry.""" + path = documentation_dir(base_dir) / REGISTRY_FILE + if not path.exists(): + return DocumentRegistry() + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + return DocumentRegistry.model_validate(raw or {}) + except (yaml.YAMLError, ValidationError): + return DocumentRegistry() + + +def _save_registry(base_dir: Path, registry: DocumentRegistry) -> None: + path = documentation_dir(base_dir) / REGISTRY_FILE + _atomic_write( + path, + yaml.safe_dump(registry.model_dump(mode="json"), sort_keys=False, allow_unicode=True), + ) + + +def scan_documents(base_dir: Path) -> ScanResult: + """Compare the folder against the registry; derives new/modified/removed.""" + doc_dir = ensure_documentation_dir(base_dir) + registry = load_registry(base_dir) + records = {record.filename: record for record in registry.documents} + present = { + path.name: path + for path in sorted(doc_dir.iterdir()) + if path.is_file() and path.name not in _EXCLUDED_FILES + } + result = ScanResult( + path=str(doc_dir), new=[], modified=[], processed=[], removed=[], unreadable=[] + ) + for name, path in present.items(): + view = DocumentView(filename=name, path=str(path), size_bytes=path.stat().st_size) + record = records.get(name) + if record is None: + result["new"].append(view) + elif record.sha256 != _sha256(path): + result["modified"].append(view) + else: + processed = ProcessedView( + filename=name, path=str(path), lane=record.lane, summary=record.summary + ) + if record.lane == "unreadable": + result["unreadable"].append(processed) + else: + result["processed"].append(processed) + result["removed"] = sorted(set(records) - set(present)) + return result + + +def mark_processed( # noqa: PLR0913 -- one keyword per registry field, all named + base_dir: Path, + filename: str, + *, + lane: Lane, + summary: str | None = None, + key_points: list[str] | None = None, + evidence_ids: list[str] | None = None, + known_evidence_ids: set[str], + today: date | None = None, +) -> DocumentRecord: + """Record the outcome for one file; replaces any previous record. + + known_evidence_ids must cover the whole corpus (packaged + personal); the + caller builds it so this module never depends on the evidence package. + """ + path = documentation_dir(base_dir) / filename + if not path.is_file(): + msg = f"{filename}: no such file in {documentation_dir(base_dir)}" + raise ValueError(msg) + unknown = [eid for eid in (evidence_ids or []) if eid not in known_evidence_ids] + if unknown: + msg = f"{filename}: evidence_ids not in the corpus: {unknown}" + raise ValueError(msg) + registry = load_registry(base_dir) + previous = {record.filename: record for record in registry.documents} + current = today or date.today() + record = DocumentRecord( + filename=filename, + sha256=_sha256(path), + size_bytes=path.stat().st_size, + first_seen=previous[filename].first_seen if filename in previous else current, + processed_on=current, + lane=lane, + summary=summary, + key_points=key_points or [], + evidence_ids=evidence_ids or [], + ) + kept = [r for r in registry.documents if r.filename != filename] + _save_registry(base_dir, DocumentRegistry(documents=[*kept, record])) + return record +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/memory/test_documents.py -q` +Expected: PASS (10 passed) + +- [ ] **Step 5: Lint, type-check, commit** + +```bash +uv run ruff check src/performance_agent/memory/documents.py tests/memory/test_documents.py +uv run ruff format src/performance_agent/memory/documents.py tests/memory/test_documents.py +uv run ty check +git add src/performance_agent/memory/documents.py tests/memory/test_documents.py +git commit -m "Add athlete documentation folder registry and scan + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 2: `write_profile` creates the folder (onboarding path) + +**Files:** +- Modify: `src/performance_agent/memory/store.py:103-108` (`write_profile`) +- Test: `tests/memory/test_documents.py` (append) + +- [ ] **Step 1: Write the failing test** (append to `tests/memory/test_documents.py`) + +```python +def test_write_profile_bootstraps_documentation_folder(tmp_path): + from performance_agent.memory import store + from performance_agent.memory.schemas import Profile + + store.write_profile(tmp_path, Profile()) + assert (tmp_path / DOCUMENTATION_DIR / README_FILE).exists() +``` + +- [ ] **Step 2: Run it** — `uv run pytest tests/memory/test_documents.py::test_write_profile_bootstraps_documentation_folder -q` — Expected: FAIL (no documentation dir) + +- [ ] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the imports block (after the `from performance_agent.memory.schemas import (...)` import): + +```python +from performance_agent.memory.documents import ensure_documentation_dir +``` + +and change `write_profile` to: + +```python +def write_profile(base_dir: Path, profile: Profile) -> Path: + """Persist the whole profile as readable YAML; returns the file path. + + Also bootstraps the documentation/ drop folder so onboarding creates it. + """ + path = base_dir / PROFILE_FILE + _atomic_write(path, _to_yaml(profile.model_dump(mode="json"))) + ensure_documentation_dir(base_dir) + return path +``` + +(Keep the original body of `write_profile` otherwise identical to what is in the file — only the docstring line and the `ensure_documentation_dir` call are added.) + +- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_documents.py tests/memory/ -q` — Expected: PASS, no regressions + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/memory/store.py tests/memory/test_documents.py +git commit -m "Bootstrap documentation folder from write_profile + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 3: MCP tools `list_athlete_documents` + `mark_document_processed` + +**Files:** +- Create: `src/performance_agent/server/document_tools.py` +- Modify: `src/performance_agent/server/app.py` +- Test: `tests/server/test_document_tools.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/server/test_document_tools.py`: + +```python +"""MCP wrappers over the documentation folder.""" + +import pytest + +from performance_agent.server import document_tools + + +@pytest.fixture +def athlete_dir(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + return tmp_path + + +def test_list_creates_folder_and_reports_new(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "study.pdf").write_bytes(b"x") + inventory = document_tools.list_athlete_documents() + assert [item["filename"] for item in inventory["new"]] == ["study.pdf"] + assert (athlete_dir / "documentation" / "README.md").exists() + + +def test_mark_validates_evidence_ids_against_corpus(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "study.pdf").write_bytes(b"x") + with pytest.raises(ValueError, match="not-a-corpus-id"): + document_tools.mark_document_processed( + "study.pdf", lane="evidence", summary="s", evidence_ids=["not-a-corpus-id"] + ) + + +def test_mark_context_then_list_shows_processed(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "notes.md").write_bytes(b"physio notes") + result = document_tools.mark_document_processed( + "notes.md", lane="context", summary="Physio: avoid loaded flexion 2 weeks." + ) + assert result["lane"] == "context" + inventory = document_tools.list_athlete_documents() + assert inventory["processed"][0]["summary"].startswith("Physio") +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/server/test_document_tools.py -q` — Expected: FAIL (module missing) + +- [ ] **Step 3: Implement** + +Create `src/performance_agent/server/document_tools.py`: + +```python +"""MCP tools for the athlete documentation drop folder. + +The server hands out paths and bookkeeping only — reading the files (PDFs +included) is the client's job, and saving a verified study goes through the +regular verify_reference/save_evidence pipeline. Lane rule: `evidence` only +when a locator resolved; everything else is `context` (used to personalize, +never cited as science) or `unreadable`. +""" + +from typing import TypedDict + +from mcp.server.fastmcp import FastMCP + +from performance_agent.evidence.corpus import load_corpus +from performance_agent.memory import documents +from performance_agent.memory.documents import Lane, ScanResult +from performance_agent.memory.paths import resolve_athlete_dir + + +class DocumentMarked(TypedDict): + """The stored registry record after marking one file.""" + + filename: str + lane: str + summary: str | None + evidence_ids: list[str] + + +def list_athlete_documents() -> ScanResult: + """Inventory the athlete's documentation/ drop folder (creates it on first call). + + Returns files split into: new (never processed), modified (content changed + since processing — process again), processed (with the stored summary, so + you know what you know without re-reading), removed (registry entry whose + file is gone), unreadable. Each pending item carries its absolute path — + read the file yourself, then record the outcome with + mark_document_processed. Never writes the registry. + """ + return documents.scan_documents(resolve_athlete_dir()) + + +def mark_document_processed( + filename: str, + lane: Lane, + summary: str | None = None, + key_points: list[str] | None = None, + evidence_ids: list[str] | None = None, +) -> DocumentMarked: + """Record what you did with one dropped file (replaces any earlier record). + + lane must follow the hard rule: `evidence` ONLY when a DOI/PMID/ISBN from + the document resolved via verify_reference and the study was saved with + save_evidence (list those corpus ids in evidence_ids — they are validated + against the corpus). Everything else is `context` (summary + key_points + persist and inform coaching, never cited as science) or `unreadable`. + summary is required except for unreadable files. + """ + record = documents.mark_processed( + resolve_athlete_dir(), + filename, + lane=lane, + summary=summary, + key_points=key_points, + evidence_ids=evidence_ids, + known_evidence_ids={entry.id for entry in load_corpus()}, + ) + return DocumentMarked( + filename=record.filename, + lane=record.lane, + summary=record.summary, + evidence_ids=list(record.evidence_ids), + ) + + +def register(mcp: FastMCP) -> None: + """Register the document tools on the server.""" + for tool in (list_athlete_documents, mark_document_processed): + mcp.tool()(tool) +``` + +In `src/performance_agent/server/app.py`, add `document_tools` to the import tuple and register it (the file lists modules alphabetically): + +```python +from performance_agent.server import ( + autoregulation_tools, + document_tools, + engine_tools, + evidence_tools, + exercise_tools, + import_tools, + macro_tools, + memory_tools, + performance_tools, + report_tools, + response_tools, + taper_tools, +) +``` + +and after `engine_tools.register(mcp)` line block add (keep existing order, append at the end of the register calls): + +```python +document_tools.register(mcp) +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/server/test_document_tools.py tests/server/ -q` — Expected: PASS + +- [ ] **Step 5: Lint, commit** + +```bash +uv run ruff check src/performance_agent/server/document_tools.py src/performance_agent/server/app.py tests/server/test_document_tools.py && uv run ruff format --check src tests +uv run ty check +git add src/performance_agent/server/document_tools.py src/performance_agent/server/app.py tests/server/test_document_tools.py +git commit -m "Add list_athlete_documents and mark_document_processed tools + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 4: Phase 1 gate + +- [ ] **Step 1: Full test suite** — Run: `uv run pytest -q` — Expected: all pass (1275+ tests, plus the new ones) +- [ ] **Step 2: Zero warnings** — Run: `uv run ruff check src tests && uv run ty check` — Expected: clean. Fix anything before moving on. + +--- + +## Phase 2 — Weekly follow-up (structured progression + engine tool + watch report + diligence) + +### Task 5: `ProgressionRule` schema + `ExerciseBlock.progression` + +**Files:** +- Modify: `src/performance_agent/memory/schemas.py` (insert `ProgressionRule` right BEFORE `class ExerciseBlock` at line 347; add one field to `ExerciseBlock`) +- Test: `tests/memory/test_schemas_progression.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/memory/test_schemas_progression.py`: + +```python +"""ProgressionRule: per-kind parameter validation and block attachment.""" + +import pytest +from pydantic import ValidationError + +from performance_agent.memory.schemas import ExerciseBlock, ProgressionRule + + +def test_double_requires_range_and_increment(): + rule = ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5) + assert rule.rounding_kg == 2.5 + with pytest.raises(ValidationError, match="rep_min"): + ProgressionRule(kind="double", rep_max=12, increment_kg=2.5) + with pytest.raises(ValidationError, match="rep_min"): + ProgressionRule(kind="double", rep_min=12, rep_max=8, increment_kg=2.5) + + +def test_linear_requires_increment(): + ProgressionRule(kind="linear_load", increment_kg=2.5) + with pytest.raises(ValidationError, match="increment_kg"): + ProgressionRule(kind="linear_load") + + +def test_rir_target_requires_target(): + rule = ProgressionRule(kind="rir_target", target_rir=2) + assert rule.adjust_pct_per_rir == 0.03 + with pytest.raises(ValidationError, match="target_rir"): + ProgressionRule(kind="rir_target") + + +def test_from_pct_and_none_take_no_required_params(): + ProgressionRule(kind="from_pct") + ProgressionRule(kind="none") + + +def test_block_accepts_structured_progression_and_stays_optional(): + block = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=4, + reps="8-12", + load_kg=80, + progression_rule="Double progression 8-12, +2.5 kg at the top.", + progression=ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + ) + assert block.progression is not None + legacy = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=4, + reps="8-12", + load_kg=80, + progression_rule="text only", + ) + assert legacy.progression is None +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: ProgressionRule`) + +- [ ] **Step 3: Implement** + +In `src/performance_agent/memory/schemas.py`, insert immediately before `class ExerciseBlock`: + +```python +class ProgressionRule(BaseModel): + """Machine-readable weekly progression; the engine computes next week from it. + + The free-text progression_rule on the block stays the human rendering; when + this structured rule is present it is the source of computation. Defaults + (3%/RIR, 2.5 kg rounding, the ±10% weekly clamp in the engine) are + team-chosen priors. + """ + + model_config = ConfigDict(extra="forbid") + + kind: Literal["double", "linear_load", "rir_target", "from_pct", "none"] + rep_min: int | None = Field(default=None, ge=1, le=100) + rep_max: int | None = Field(default=None, ge=1, le=100) + increment_kg: float | None = Field(default=None, gt=0, le=50) + target_rir: float | None = Field(default=None, ge=0, le=10) + adjust_pct_per_rir: float = Field(default=0.03, gt=0, le=0.2) + rounding_kg: float = Field(default=2.5, gt=0, le=10) + + @model_validator(mode="after") + def _params_match_kind(self) -> Self: + if self.kind == "double": + if self.rep_min is None or self.rep_max is None or self.increment_kg is None: + msg = "kind=double requires rep_min, rep_max and increment_kg" + raise ValueError(msg) + if self.rep_min >= self.rep_max: + msg = f"rep_min must be < rep_max, got {self.rep_min}..{self.rep_max}" + raise ValueError(msg) + if self.kind == "linear_load" and self.increment_kg is None: + msg = "kind=linear_load requires increment_kg" + raise ValueError(msg) + if self.kind == "rir_target" and self.target_rir is None: + msg = "kind=rir_target requires target_rir" + raise ValueError(msg) + return self +``` + +In `ExerciseBlock`, add after the `progression_rule: str = Field(min_length=1)` line: + +```python + progression: ProgressionRule | None = None +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_schemas_progression.py tests/memory/ -q` — Expected: PASS (old program yaml without the field still loads: covered by existing store tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/memory/schemas.py tests/memory/test_schemas_progression.py +git commit -m "Add structured ProgressionRule to exercise blocks + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 6: `engine/progression.py` — pure next-load math + +**Files:** +- Create: `src/performance_agent/engine/progression.py` +- Test: `tests/engine/test_progression.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/engine/test_progression.py`: + +```python +"""Pure next-load math for each ProgressionRule kind.""" + +import pytest + +from performance_agent.engine.progression import ( + SetActual, + next_load_double, + next_load_from_pct, + next_load_linear, + next_load_rir, + round_to_increment, +) +from performance_agent.memory.schemas import ProgressionRule + +DOUBLE = ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5) +LINEAR = ProgressionRule(kind="linear_load", increment_kg=2.5) +RIR = ProgressionRule(kind="rir_target", target_rir=2) + + +def sets(*reps, load=80.0, rir=None): + return [SetActual(reps=r, load_kg=load, rir=rir) for r in reps] + + +def test_rounding(): + assert round_to_increment(81.4, 2.5) == 82.5 + assert round_to_increment(81.1, 2.5) == 80.0 + + +def test_double_top_of_range_increments(): + result = next_load_double(DOUBLE, 80.0, sets(12, 12, 12, 12)) + assert result.next_load_kg == 82.5 + assert result.action == "increment" + assert result.flags == () + + +def test_double_mid_range_holds(): + result = next_load_double(DOUBLE, 80.0, sets(12, 12, 12, 11)) + assert result.next_load_kg == 80.0 + assert result.action == "hold" + + +def test_double_below_rep_min_holds_with_failed_flag(): + result = next_load_double(DOUBLE, 80.0, sets(8, 7, 6)) + assert result.next_load_kg == 80.0 + assert result.action == "hold" + assert "failed_sets" in result.flags + + +def test_double_no_sets_flags_unmatched(): + result = next_load_double(DOUBLE, 80.0, []) + assert result.next_load_kg is None + assert "no_logged_sets" in result.flags + + +def test_linear_all_sets_at_prescribed_reps_increment(): + result = next_load_linear(LINEAR, 100.0, 5, sets(5, 5, 5, load=100.0)) + assert result.next_load_kg == 102.5 + assert result.action == "increment" + + +def test_linear_missed_reps_holds_with_flag(): + result = next_load_linear(LINEAR, 100.0, 5, sets(5, 4, 3, load=100.0)) + assert result.next_load_kg == 100.0 + assert "failed_sets" in result.flags + + +def test_rir_above_target_raises_load(): + # mean RIR 4 vs target 2 -> +6% on 100 kg -> 106 -> rounds to 105 + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0, rir=4)) + assert result.next_load_kg == 105.0 + assert result.action == "increment" + + +def test_rir_below_target_lowers_load(): + # mean RIR 0 vs target 2 -> -6% -> 94 -> rounds to 95 + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0, rir=0)) + assert result.next_load_kg == 95.0 + assert result.action == "decrement" + + +def test_rir_clamped_to_ten_percent(): + # mean RIR 8 vs target 2 -> raw +18% -> clamped to +10% -> 110 + result = next_load_rir(RIR, 100.0, sets(5, load=100.0, rir=8)) + assert result.next_load_kg == 110.0 + assert "clamped" in result.flags + + +def test_rir_without_logged_rir_holds(): + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0)) + assert result.next_load_kg == 100.0 + assert "no_rir_logged" in result.flags + + +def test_from_pct_resolves_next_week_pct(): + result = next_load_from_pct(0.85, 140.0, 2.5) + assert result.next_load_kg == 120.0 + assert result.action == "per_plan" + + +def test_from_pct_without_e1rm_flags(): + result = next_load_from_pct(0.85, None, 2.5) + assert result.next_load_kg is None + assert "no_e1rm" in result.flags +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/engine/test_progression.py -q` — Expected: FAIL (module missing) + +- [ ] **Step 3: Implement** + +Create `src/performance_agent/engine/progression.py`: + +```python +"""Pure weekly progression math: one function per ProgressionRule kind. + +No I/O, no dates. The memory layer (memory/weekly_review.py) matches logs to +blocks and dispatches here. The ±10% weekly cap on autoregulated (RIR) load +moves and the round-half-up-to-increment rounding are team-chosen priors. +""" + +from dataclasses import dataclass + +from performance_agent.memory.schemas import ProgressionRule + +# Autoregulated weekly load moves are capped at ±10% (team-chosen prior): +# a single week's mean RIR should nudge the load, not rewrite it. +_MAX_RIR_ADJUST_PCT = 0.10 + + +@dataclass(frozen=True) +class SetActual: + """One logged set, already matched to the block being progressed.""" + + reps: int + load_kg: float + rir: float | None = None + + +@dataclass(frozen=True) +class LoadSuggestion: + """The engine's verdict for one block's next week.""" + + next_load_kg: float | None + action: str # increment | hold | decrement | per_plan + flags: tuple[str, ...] = () + + +def round_to_increment(value: float, step: float) -> float: + """Round to the nearest plate step (2.5 kg default upstream).""" + if step <= 0: + msg = f"step must be positive, got {step!r}" + raise ValueError(msg) + return round(value / step) * step + + +def next_load_double( + rule: ProgressionRule, current_load_kg: float, sets: list[SetActual] +) -> LoadSuggestion: + """Double progression: all sets at rep_max -> add increment, else hold.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + assert rule.rep_max is not None and rule.rep_min is not None # noqa: S101 -- schema-validated + assert rule.increment_kg is not None # noqa: S101 -- schema-validated + if all(s.reps >= rule.rep_max for s in sets): + raised = round_to_increment(current_load_kg + rule.increment_kg, rule.rounding_kg) + return LoadSuggestion(raised, "increment") + flags = ("failed_sets",) if any(s.reps < rule.rep_min for s in sets) else () + return LoadSuggestion(current_load_kg, "hold", flags) + + +def next_load_linear( + rule: ProgressionRule, + current_load_kg: float, + prescribed_reps: int, + sets: list[SetActual], +) -> LoadSuggestion: + """Linear load: every set hit the prescribed reps -> add increment, else hold.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + assert rule.increment_kg is not None # noqa: S101 -- schema-validated + if all(s.reps >= prescribed_reps for s in sets): + raised = round_to_increment(current_load_kg + rule.increment_kg, rule.rounding_kg) + return LoadSuggestion(raised, "increment") + return LoadSuggestion(current_load_kg, "hold", ("failed_sets",)) + + +def next_load_rir( + rule: ProgressionRule, current_load_kg: float, sets: list[SetActual] +) -> LoadSuggestion: + """RIR-target autoregulation: adjust_pct_per_rir per point of mean deviation.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + assert rule.target_rir is not None # noqa: S101 -- schema-validated + rirs = [s.rir for s in sets if s.rir is not None] + if not rirs: + return LoadSuggestion(current_load_kg, "hold", ("no_rir_logged",)) + delta = sum(rirs) / len(rirs) - rule.target_rir + adjust = rule.adjust_pct_per_rir * delta + flags: tuple[str, ...] = () + if abs(adjust) > _MAX_RIR_ADJUST_PCT: + adjust = _MAX_RIR_ADJUST_PCT if adjust > 0 else -_MAX_RIR_ADJUST_PCT + flags = ("clamped",) + raised = round_to_increment(current_load_kg * (1 + adjust), rule.rounding_kg) + if raised > current_load_kg: + action = "increment" + elif raised < current_load_kg: + action = "decrement" + else: + action = "hold" + return LoadSuggestion(raised, action, flags) + + +def next_load_from_pct( + next_pct_1rm: float, e1rm_kg: float | None, rounding_kg: float +) -> LoadSuggestion: + """Percent-planned blocks: next week's planned pct resolved against e1RM.""" + if e1rm_kg is None: + return LoadSuggestion(None, "per_plan", ("no_e1rm",)) + return LoadSuggestion(round_to_increment(next_pct_1rm * e1rm_kg, rounding_kg), "per_plan") +``` + +Note: `assert` here documents schema-guaranteed invariants (the Pydantic validator makes them unreachable); if the repo's ruff config rejects `S101`, replace each assert with an explicit `if x is None: raise ValueError(...)` guard. + +- [ ] **Step 4: Run** — `uv run pytest tests/engine/test_progression.py tests/engine/test_engine_purity.py -q` — Expected: PASS (purity test must still pass — this module imports only schemas + dataclasses) + +- [ ] **Step 5: Commit** + +```bash +uv run ruff check src/performance_agent/engine/progression.py tests/engine/test_progression.py +git add src/performance_agent/engine/progression.py tests/engine/test_progression.py +git commit -m "Add pure next-load progression engine + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 7: `memory/weekly_review.py` — match the logged week, dispatch rules + +**Files:** +- Create: `src/performance_agent/memory/weekly_review.py` +- Test: `tests/memory/test_weekly_review.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/memory/test_weekly_review.py`. It needs a small program fixture; build it inline: + +```python +"""Weekly loads review: week matching, rule dispatch, state file.""" + +from datetime import date, datetime + +import pytest + +from performance_agent.memory import store, weekly_review +from performance_agent.memory.schemas import ( + ExerciseBlock, + ExercisePerformed, + Fallbacks, + Mesocycle, + Profile, + ProgressionRule, + ProgramPlan, + SessionEntry, + SessionPlan, + SetPerformed, + WeekPlan, +) + +TODAY = date(2026, 7, 17) +FALLBACKS = Fallbacks(low_readiness="halve", short_on_time="cut accessories", missing_equipment="dumbbells") + + +def _block(exercise="Bench press", **overrides): + fields = dict( + exercise=exercise, + priority="primary", + sets=3, + reps="8-12", + load_kg=80.0, + rest_s=120, + progression_rule="Double progression 8-12, +2.5 kg at the top.", + progression=ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + ) + fields.update(overrides) + return ExerciseBlock(**fields) + + +def _week(index, blocks): + return WeekPlan( + week_index=index, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id=f"w{index}-a", + weekday=0, + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper strength", + blocks=blocks, + fallbacks=FALLBACKS, + ) + ], + ) + + +def _save_program(base, weeks): + plan = ProgramPlan( + version=1, + goal_id="bench-goal", + created_on=TODAY, + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=weeks)], + ) + store.save_program(base, plan, today=TODAY) + + +def _log(base, day, session_plan_id, exercise="Bench press", reps=(12, 12, 12), load=80.0, rir=None): + entry = SessionEntry( + performed_at=datetime(day.year, day.month, day.day, 18, 0), + session_plan_id=session_plan_id, + exercises=[ + ExercisePerformed( + name=exercise, + sets=[SetPerformed(reps=r, load_kg=load, rir=rir) for r in reps], + ) + ], + ) + store.append_session(base, entry) + + +def test_no_program_raises(tmp_path): + with pytest.raises(ValueError, match="no program"): + weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + + +def test_double_progression_increment_end_to_end(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert view["week_matched"] == 1 + [block] = view["blocks"] + assert block["exercise"] == "Bench press" + assert block["next_load_kg"] == 82.5 + assert block["rationale_key"] == "increment" + + +def test_unmatched_block_is_flagged_not_guessed(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program( + tmp_path, + [_week(1, [_block(), _block(exercise="Squat")]), _week(2, [_block()])], + ) + _log(tmp_path, date(2026, 7, 13), "w1-a") # only bench logged + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + squat = next(b for b in view["blocks"] if b["exercise"] == "Squat") + assert squat["next_load_kg"] is None + assert "no_logged_sets" in squat["flags"] + + +def test_block_without_structured_rule_flags_no_rule(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block(progression=None)]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + [block] = view["blocks"] + assert block["rationale_key"] == "no_rule" + assert block["next_load_kg"] is None + + +def test_from_pct_uses_next_week_pct_and_logged_e1rm(tmp_path): + store.write_profile(tmp_path, Profile()) + week1 = _week( + 1, + [_block(load_kg=None, pct_1rm=0.8, progression=ProgressionRule(kind="from_pct"))], + ) + week2 = _week( + 2, + [_block(load_kg=None, pct_1rm=0.85, progression=ProgressionRule(kind="from_pct"))], + ) + _save_program(tmp_path, [week1, week2]) + # best set 100x5 -> Epley e1RM 116.7 -> 0.85 * 116.7 = 99.2 -> rounds to 100 + _log(tmp_path, date(2026, 7, 13), "w1-a", reps=(5, 5), load=100.0) + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + [block] = view["blocks"] + assert block["next_load_kg"] == 100.0 + assert block["rationale_key"] == "per_plan" + + +def test_state_file_records_the_run(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + assert weekly_review.read_last_run(tmp_path) is None + weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert weekly_review.read_last_run(tmp_path) == TODAY + + +def test_no_matching_week_returns_empty_with_flag(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert view["week_matched"] is None + assert view["blocks"] == [] + assert "no_matched_week" in view["flags"] +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: FAIL (module missing) + +- [ ] **Step 3: Implement** + +Create `src/performance_agent/memory/weekly_review.py`: + +```python +"""Weekly loads review: match the logged week to the program, apply each rule. + +Deterministic given `today`. Matching: sessions logged in the last `days_back` +days are matched to program sessions by session_plan_id when present, else by +exercise-name overlap; the program week with the most matched sessions is the +current week (tie -> highest week_index). Suggestions target each block's next +occurrence: same-load rules (double/linear/rir_target) progress the current +block; from_pct resolves the SAME exercise's planned pct in the following week +(fallback: the current block's pct). e1RM comes from the best logged set of +that exercise in the last 14 days (Epley), falling back to the profile's +lift_inventory. A successful run records its date so diligence can see it. +""" + +from datetime import date +from pathlib import Path +from typing import TypedDict + +import yaml + +from performance_agent.engine.progression import ( + SetActual, + next_load_double, + next_load_from_pct, + next_load_linear, + next_load_rir, +) +from performance_agent.engine.strength import MAX_ESTIMATION_REPS, one_rm_epley +from performance_agent.memory import store +from performance_agent.memory.schemas import ( + ExerciseBlock, + ProgramPlan, + SessionEntry, + SessionPlan, + WeekPlan, +) +from performance_agent.programs.render import intensity_label, volume_label + +LOADS_REVIEW_STATE_FILE = "loads-review.yaml" +_DEFAULT_WINDOW_DAYS = 7 +_E1RM_WINDOW_DAYS = 14 + + +class ActualSetView(TypedDict): + """One logged set as facts.""" + + reps: int + load_kg: float + rir: float | None + + +class BlockSuggestionView(TypedDict): + """Next-week verdict for one block (facts; the LLM renders the sentence).""" + + session_id: str + exercise: str + rule_kind: str | None + prescribed_volume: str + prescribed_intensity: str + actual_sets: list[ActualSetView] + next_load_kg: float | None + rationale_key: str + flags: list[str] + + +class WeeklyLoadsView(TypedDict): + """The whole review: matched week, one verdict per block, run-level flags.""" + + week_matched: int | None + blocks: list[BlockSuggestionView] + flags: list[str] + + +def read_last_run(base_dir: Path) -> date | None: + """Date of the last successful review, or None when never run.""" + path = base_dir / LOADS_REVIEW_STATE_FILE + if not path.exists(): + return None + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or "last_run" not in raw: + return None + return date.fromisoformat(str(raw["last_run"])) + + +def _record_run(base_dir: Path, current: date) -> None: + path = base_dir / LOADS_REVIEW_STATE_FILE + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(yaml.safe_dump({"last_run": current.isoformat()}), encoding="utf-8") + tmp.replace(path) + + +def _weeks_in_order(plan: ProgramPlan) -> list[WeekPlan]: + return [week for meso in plan.mesocycles for week in meso.weeks] + + +def _window_sessions(base_dir: Path, current: date, days_back: int) -> list[SessionEntry]: + return [ + entry + for entry in store.read_sessions(base_dir) + if entry.source == "programmed" + and 0 <= (current - entry.performed_at.date()).days < days_back + ] + + +def _logged_names(entries: list[SessionEntry]) -> set[str]: + return {ex.name.casefold() for entry in entries for ex in entry.exercises} + + +def _match_week(weeks: list[WeekPlan], logged: list[SessionEntry]) -> WeekPlan | None: + plan_ids = {entry.session_plan_id for entry in logged if entry.session_plan_id} + names = _logged_names(logged) + + def score(week: WeekPlan) -> int: + if plan_ids: + return sum(1 for session in week.sessions if session.id in plan_ids) + return sum( + 1 + for session in week.sessions + for block in session.blocks + if block.exercise.casefold() in names + ) + + best = max(weeks, key=lambda week: (score(week), week.week_index), default=None) + if best is None or score(best) == 0: + return None + return best + + +def _sets_for_block(block: ExerciseBlock, logged: list[SessionEntry]) -> list[SetActual]: + wanted = block.exercise.casefold() + return [ + SetActual(reps=s.reps, load_kg=s.load_kg, rir=s.rir) + for entry in logged + for ex in entry.exercises + if ex.name.casefold() == wanted + for s in ex.sets + ] + + +def _e1rm_for(base_dir: Path, exercise: str, current: date) -> float | None: + wanted = exercise.casefold() + best: float | None = None + for entry in store.read_sessions(base_dir): + if not 0 <= (current - entry.performed_at.date()).days < _E1RM_WINDOW_DAYS: + continue + for ex in entry.exercises: + if ex.name.casefold() != wanted: + continue + for s in ex.sets: + if s.load_kg <= 0 or not 1 <= s.reps <= MAX_ESTIMATION_REPS: + continue + estimate = one_rm_epley(s.load_kg, s.reps) + best = estimate if best is None else max(best, estimate) + if best is not None: + return best + profile = store.read_profile(base_dir) + for record in profile.lift_inventory: + if record.lift.casefold() == wanted: + return record.one_rm_kg + return None + + +def _next_pct_for(block: ExerciseBlock, next_week: WeekPlan | None) -> float | None: + if next_week is not None: + for session in next_week.sessions: + for candidate in session.blocks: + if candidate.exercise.casefold() == block.exercise.casefold(): + if candidate.pct_1rm is not None: + return candidate.pct_1rm + return block.pct_1rm + + +def _suggest_block( # noqa: PLR0911 -- one return per rule kind is the readable shape + base_dir: Path, + session: SessionPlan, + block: ExerciseBlock, + logged: list[SessionEntry], + next_week: WeekPlan | None, + current: date, +) -> BlockSuggestionView: + view = BlockSuggestionView( + session_id=session.id, + exercise=block.exercise, + rule_kind=block.progression.kind if block.progression else None, + prescribed_volume=volume_label(block), + prescribed_intensity=intensity_label(block), + actual_sets=[], + next_load_kg=None, + rationale_key="no_rule", + flags=[], + ) + sets = _sets_for_block(block, logged) + view["actual_sets"] = [ + ActualSetView(reps=s.reps, load_kg=s.load_kg, rir=s.rir) for s in sets + ] + rule = block.progression + if rule is None: + return view + if rule.kind == "none": + view["rationale_key"] = "per_plan" + return view + if rule.kind == "from_pct": + pct = _next_pct_for(block, next_week) + if pct is None: + view["rationale_key"] = "per_plan" + view["flags"] = ["no_pct_prescribed"] + return view + result = next_load_from_pct(pct, _e1rm_for(base_dir, block.exercise, current), rule.rounding_kg) + elif rule.kind == "double": + result = next_load_double(rule, block.load_kg or 0.0, sets) + elif rule.kind == "linear_load": + reps = block.reps or "" + if not reps.isdigit(): + view["rationale_key"] = "hold" + view["flags"] = ["ambiguous_reps"] + return view + result = next_load_linear(rule, block.load_kg or 0.0, int(reps), sets) + else: # rir_target + result = next_load_rir(rule, block.load_kg or 0.0, sets) + view["next_load_kg"] = result.next_load_kg + view["rationale_key"] = result.action + view["flags"] = list(result.flags) + return view + + +def suggest_next_week_loads( + base_dir: Path, today: date | None = None, days_back: int = _DEFAULT_WINDOW_DAYS +) -> WeeklyLoadsView: + """Compute every block's next-week load from the logged week (see module doc).""" + if days_back < 1: + msg = f"days_back must be >= 1, got {days_back!r}" + raise ValueError(msg) + current = today or date.today() + program = store.read_program(base_dir) + if program is None: + msg = "no program has been saved yet; save a program before a loads review" + raise ValueError(msg) + if program.plan is None: + msg = "the active program is legacy prose-only; a structured plan is required" + raise ValueError(msg) + weeks = _weeks_in_order(program.plan) + logged = _window_sessions(base_dir, current, days_back) + week = _match_week(weeks, logged) + if week is None: + return WeeklyLoadsView(week_matched=None, blocks=[], flags=["no_matched_week"]) + position = weeks.index(week) + next_week = weeks[position + 1] if position + 1 < len(weeks) else None + flags = [] if next_week is not None else ["last_week"] + blocks = [ + _suggest_block(base_dir, session, block, logged, next_week, current) + for session in week.sessions + for block in session.blocks + ] + _record_run(base_dir, current) + return WeeklyLoadsView(week_matched=week.week_index, blocks=blocks, flags=flags) +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: PASS. If the Epley expectation in `test_from_pct_uses_next_week_pct_and_logged_e1rm` is off by one rounding step, recompute by hand (100 × (1 + 5/30) = 116.67; 0.85 × 116.67 = 99.17 → 100.0 at 2.5 rounding) — fix the TEST only if your arithmetic disagrees, never the engine. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff check src/performance_agent/memory/weekly_review.py tests/memory/test_weekly_review.py +git add src/performance_agent/memory/weekly_review.py tests/memory/test_weekly_review.py +git commit -m "Add weekly loads review with week matching and rule dispatch + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 8: Watch report versioned doc in the store + +**Files:** +- Modify: `src/performance_agent/memory/store.py` (constants + three functions, after the nutrition-frame block at the end of the file) +- Test: `tests/memory/test_store_watch.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/memory/test_store_watch.py`: + +```python +"""Watch reports: immutable versioned docs under watch/.""" + +import pytest + +from performance_agent.memory import store + + +def test_first_report_is_v1_and_readable(tmp_path): + path, version = store.save_watch_report(tmp_path, "All lifts on track.", "goal-1") + assert version == 1 + assert path == tmp_path / "watch" / "report-v1.md" + frontmatter, body = store.read_watch_report(tmp_path) + assert frontmatter["version"] == 1 + assert body == "All lifts on track." + + +def test_v2_requires_reason(tmp_path): + store.save_watch_report(tmp_path, "v1", "goal-1") + with pytest.raises(ValueError, match="reason"): + store.save_watch_report(tmp_path, "v2", "goal-1") + _, version = store.save_watch_report(tmp_path, "v2", "goal-1", reason="biweekly watch") + assert version == 2 + + +def test_latest_version_none_when_empty(tmp_path): + assert store.latest_watch_report_version(tmp_path) is None +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_store_watch.py -q` — Expected: FAIL (`AttributeError: save_watch_report`) + +- [ ] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the constants block (after `EXERCISE_LIBRARY_FILE`): + +```python +WATCH_DIR = "watch" +``` + +and append at the end of the file: + +```python +def latest_watch_report_version(base_dir: Path) -> int | None: + """Return the highest existing watch-report version, or None.""" + return _latest_doc_version(base_dir, WATCH_DIR, "report") + + +def save_watch_report( + base_dir: Path, + markdown_body: str, + goal_id: str, + reason: str | None = None, + today: date | None = None, +) -> tuple[Path, int]: + """Write the next program-watch report version; v2+ requires a reason. + + Same immutable-version audit trail as the other doc families; lives in + watch/. The latest report's created_on is also the diligence anchor for + "program watch due". + """ + return _save_versioned_doc( + base_dir, + markdown_body, + goal_id, + subdir=WATCH_DIR, + prefix="report", + label="watch report", + reason=reason, + today=today, + ) + + +def read_watch_report( + base_dir: Path, version: int | None = None +) -> tuple[dict[str, object], str] | None: + """Return (frontmatter, body) for the given or latest watch report; None when empty.""" + return _read_versioned_doc( + base_dir, + subdir=WATCH_DIR, + prefix="report", + label="watch report", + version=version, + ) +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_store_watch.py tests/memory/ -q` — Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/memory/store.py tests/memory/test_store_watch.py +git commit -m "Add watch report versioned doc family + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 9: MCP tools `suggest_next_week_loads` + `save_watch_report` + +**Files:** +- Create: `src/performance_agent/server/followup_tools.py` +- Modify: `src/performance_agent/server/app.py` +- Test: `tests/server/test_followup_tools.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/server/test_followup_tools.py`: + +```python +"""MCP wrappers for the weekly follow-up.""" + +from datetime import date, datetime + +import pytest + +from performance_agent.memory import store +from performance_agent.memory.schemas import ( + ExerciseBlock, + ExercisePerformed, + Fallbacks, + Mesocycle, + Profile, + ProgressionRule, + ProgramPlan, + SessionEntry, + SessionPlan, + SetPerformed, + WeekPlan, +) +from performance_agent.server import followup_tools + + +@pytest.fixture +def athlete_dir(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + store.write_profile(tmp_path, Profile()) + return tmp_path + + +def _seed_program_and_log(base): + block = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8-12", + load_kg=80.0, + progression_rule="Double progression 8-12, +2.5 kg at the top.", + progression=ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + ) + week = WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="w1-a", + weekday=0, + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[block], + fallbacks=Fallbacks( + low_readiness="halve", short_on_time="cut", missing_equipment="dumbbells" + ), + ) + ], + ) + plan = ProgramPlan( + version=1, + goal_id="bench-goal", + created_on=date(2026, 7, 13), + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=[week])], + ) + store.save_program(base, plan, today=date(2026, 7, 13)) + store.append_session( + base, + SessionEntry( + performed_at=datetime(2026, 7, 15, 18, 0), + session_plan_id="w1-a", + exercises=[ + ExercisePerformed( + name="Bench press", + sets=[SetPerformed(reps=12, load_kg=80.0) for _ in range(3)], + ) + ], + ), + ) + + +def test_suggest_returns_block_verdicts(athlete_dir): + _seed_program_and_log(athlete_dir) + view = followup_tools.suggest_next_week_loads() + assert view["week_matched"] == 1 + assert view["blocks"][0]["next_load_kg"] == 82.5 + + +def test_suggest_rejects_bad_window(athlete_dir): + with pytest.raises(ValueError, match="days_back"): + followup_tools.suggest_next_week_loads(days_back=0) + + +def test_save_watch_report_versions(athlete_dir): + result = followup_tools.save_watch_report("All on track.", "bench-goal") + assert result["version"] == 1 +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/server/test_followup_tools.py -q` — Expected: FAIL (module missing) + +- [ ] **Step 3: Implement** + +Create `src/performance_agent/server/followup_tools.py`: + +```python +"""MCP tools for the weekly follow-up: loads review and watch reports.""" + +from mcp.server.fastmcp import FastMCP + +from performance_agent.memory import store, weekly_review +from performance_agent.memory.paths import resolve_athlete_dir +from performance_agent.memory.weekly_review import WeeklyLoadsView +from performance_agent.server.memory_tools import VersionedDocSaved + + +def suggest_next_week_loads(days_back: int = 7) -> WeeklyLoadsView: + """Compute next week's load for every block of the logged program week. + + Deterministic engine math, zero guessing: logged sessions from the last + days_back days are matched to the program (session_plan_id first, exercise + names as fallback); each block's structured progression rule then yields + {next_load_kg, rationale_key, flags}. Degraded cases are flags, never + guesses: no_rule (unstructured block — handle it conversationally), + no_logged_sets, failed_sets (hold), no_rir_logged, no_e1rm, clamped, + ambiguous_reps; week-level: no_matched_week, last_week. Quote the numbers + and the rationale to the athlete; this NEVER modifies the program. A + successful run is recorded so list_due_actions can see the review happened. + """ + return weekly_review.suggest_next_week_loads(resolve_athlete_dir(), days_back=days_back) + + +def save_watch_report( + markdown_body: str, goal_id: str, reason: str | None = None +) -> VersionedDocSaved: + """Write the NEXT program-watch report version (immutable audit trail). + + The report is the program-watch skill's output: per-exercise verdicts + (keep / watch / substitution candidate) with the data behind each one. + Version 1 needs no reason; every later report (v2+) requires a reason + naming its trigger (biweekly watch, mesocycle boundary, athlete request). + Saving also timestamps the watch for list_due_actions. + """ + path, version = store.save_watch_report( + resolve_athlete_dir(), markdown_body, goal_id, reason + ) + return VersionedDocSaved(path=str(path), version=version) + + +def register(mcp: FastMCP) -> None: + """Register the follow-up tools on the server.""" + for tool in (suggest_next_week_loads, save_watch_report): + mcp.tool()(tool) +``` + +In `src/performance_agent/server/app.py`, add `followup_tools` to the import tuple (alphabetical: between `exercise_tools` and `import_tools`) and add `followup_tools.register(mcp)` after `document_tools.register(mcp)`. + +- [ ] **Step 4: Run** — `uv run pytest tests/server/test_followup_tools.py tests/server/ -q` — Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +uv run ruff check src/performance_agent/server/followup_tools.py src/performance_agent/server/app.py tests/server/test_followup_tools.py +git add src/performance_agent/server/followup_tools.py src/performance_agent/server/app.py tests/server/test_followup_tools.py +git commit -m "Add suggest_next_week_loads and save_watch_report tools + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 10: Diligence — `loads_review` and `program_watch` due actions + +**Files:** +- Modify: `src/performance_agent/engine/diligence.py` (facts + two action builders + thresholds) +- Modify: `src/performance_agent/memory/diligence.py` (fact extraction) +- Test: `tests/engine/test_diligence.py` (append), `tests/memory/` covered via server tests + +- [ ] **Step 1: Write the failing tests** (append to `tests/engine/test_diligence.py`) + +```python +def test_loads_review_due_when_sessions_logged_and_never_reviewed(): + from performance_agent.engine.diligence import DiligenceFacts, list_due_actions + + facts = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=3, + days_since_loads_review=None, + ) + kinds = {action.kind for action in list_due_actions(facts)} + assert "loads_review" in kinds + + +def test_loads_review_quiet_without_recent_sessions_or_when_fresh(): + from performance_agent.engine.diligence import DiligenceFacts, list_due_actions + + quiet = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=0, + days_since_loads_review=None, + ) + fresh = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=3, + days_since_loads_review=2, + ) + for facts in (quiet, fresh): + kinds = {action.kind for action in list_due_actions(facts)} + assert "loads_review" not in kinds + + +def test_program_watch_due_after_fourteen_days(): + from performance_agent.engine.diligence import DiligenceFacts, list_due_actions + + facts = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + days_since_watch_anchor=15, + ) + kinds = {action.kind for action in list_due_actions(facts)} + assert "program_watch" in kinds + + +def test_program_watch_quiet_when_recent_or_no_program(): + from performance_agent.engine.diligence import DiligenceFacts, list_due_actions + + recent = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + days_since_watch_anchor=13, + ) + no_program = DiligenceFacts( + has_program=False, + checkin_cadence_days=7, + days_since_watch_anchor=100, + ) + for facts in (recent, no_program): + kinds = {action.kind for action in list_due_actions(facts)} + assert "program_watch" not in kinds +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/engine/test_diligence.py -q` — Expected: FAIL (`unexpected keyword argument 'sessions_logged_last_week'`) + +- [ ] **Step 3: Implement** + +In `src/performance_agent/engine/diligence.py`: + +Add to the thresholds block (after `_RED_STREAK_HIGH = 3`): + +```python +# A training week with logged sessions deserves a loads review within six days; +# a running program deserves a watch pass every two weeks (team-chosen priors). +_LOADS_REVIEW_DUE_DAYS = 6 +_WATCH_DUE_DAYS = 14 +``` + +Add two fields at the end of `DiligenceFacts`: + +```python + sessions_logged_last_week: int = 0 + days_since_loads_review: int | None = None + days_since_watch_anchor: int | None = None +``` + +(and extend the class docstring with: `days_since_loads_review is None when no review was ever recorded; days_since_watch_anchor counts from the newest of program start / last watch report, None without a program.`) + +Add two action builders (after `_red_streak_action`): + +```python +def _loads_review_action(facts: DiligenceFacts) -> DueAction | None: + if not facts.has_program or facts.sessions_logged_last_week < 1: + return None + never = facts.days_since_loads_review is None + if not never and (facts.days_since_loads_review or 0) < _LOADS_REVIEW_DUE_DAYS: + return None + return DueAction( + "loads_review", + "medium", + "loads_review_due", + due_since_days=facts.days_since_loads_review, + ) + + +def _watch_action(facts: DiligenceFacts) -> DueAction | None: + if not facts.has_program or facts.days_since_watch_anchor is None: + return None + if facts.days_since_watch_anchor < _WATCH_DUE_DAYS: + return None + return DueAction( + "program_watch", + "medium", + "program_watch_due", + due_since_days=facts.days_since_watch_anchor, + ) +``` + +Register both in the `candidates` list inside `list_due_actions` (after `_red_streak_action(facts),`): + +```python + _loads_review_action(facts), + _watch_action(facts), +``` + +In `src/performance_agent/memory/diligence.py`: + +Add imports: `from performance_agent.memory import weekly_review` (with the existing memory imports). + +Add two helpers (after `_readiness_red_streak`): + +```python +def _sessions_logged_last_week(base_dir: Path, current: date) -> int: + return sum( + 1 + for entry in store.read_sessions(base_dir) + if 0 <= (current - entry.performed_at.date()).days < _MISSED_WINDOW_DAYS + ) + + +def _days_since_loads_review(base_dir: Path, current: date) -> int | None: + last_run = weekly_review.read_last_run(base_dir) + return None if last_run is None else (current - last_run).days + + +def _days_since_watch_anchor(base_dir: Path, current: date) -> int | None: + """Days since the newest of program start / latest watch report, or None.""" + program = store.read_program(base_dir) + if program is None: + return None + anchor = date.fromisoformat(program.created_on) + report = store.read_watch_report(base_dir) + if report is not None: + frontmatter, _ = report + anchor = max(anchor, date.fromisoformat(str(frontmatter["created_on"]))) + return (current - anchor).days +``` + +And extend `_build_facts`'s returned `DiligenceFacts(...)` with: + +```python + sessions_logged_last_week=_sessions_logged_last_week(base_dir, current), + days_since_loads_review=_days_since_loads_review(base_dir, current), + days_since_watch_anchor=_days_since_watch_anchor(base_dir, current), +``` + +Also update the `list_due_actions` tool docstring in `src/performance_agent/server/memory_tools.py` (lines 564-577): extend the surfaced list with `a finished training week that never got its loads review, and a program unaudited for two weeks (program watch)`. + +- [ ] **Step 4: Run** — `uv run pytest tests/engine/test_diligence.py tests/memory/ tests/server/ -q` — Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/engine/diligence.py src/performance_agent/memory/diligence.py src/performance_agent/server/memory_tools.py tests/engine/test_diligence.py +git commit -m "Surface loads-review and program-watch due actions + +Co-Authored-By: Claude Fable 5 " +``` + +### Phase 2 gate + +- [ ] Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: all green, zero warnings. + +--- + +## Phase 3 — Science in the deliverables + +### Task 11: `Guidance` schema + `ProgramPlan.advice` / `rationale` + +**Files:** +- Modify: `src/performance_agent/memory/schemas.py` (insert `Guidance` right BEFORE `class ProgramPlan` at line ~456; add two fields to `ProgramPlan`) +- Test: `tests/memory/test_schemas_progression.py` (append) + +- [ ] **Step 1: Write the failing test** (append to `tests/memory/test_schemas_progression.py`) + +```python +def test_program_plan_carries_optional_guidance(): + from datetime import date + + from performance_agent.memory.schemas import ( + Fallbacks, + Guidance, + Mesocycle, + ProgramPlan, + SessionPlan, + WeekPlan, + ) + + week = WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="a", + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[ + ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8", + load_kg=80, + progression_rule="hold", + ) + ], + fallbacks=Fallbacks( + low_readiness="halve", short_on_time="cut", missing_equipment="dumbbells" + ), + ) + ], + ) + plan = ProgramPlan( + version=1, + goal_id="g", + created_on=date(2026, 7, 17), + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=[week])], + advice=[Guidance(text="Creatine 5 g/day.", cite="creatine-2017")], + rationale=[Guidance(text="12-16 hard sets per muscle per week.")], + ) + assert plan.advice[0].cite == "creatine-2017" + assert plan.rationale[0].cite is None + bare = plan.model_copy(update={"advice": [], "rationale": []}) + assert bare.advice == [] +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: Guidance`) + +- [ ] **Step 3: Implement** — in `src/performance_agent/memory/schemas.py`, insert before `class ProgramPlan`: + +```python +class Guidance(BaseModel): + """One header guidance line: advice or program rationale, optionally cited. + + cite is a corpus id (same semantics as ExerciseBlock.cite). Without one the + line must read as coaching judgment — never a fake citation. + """ + + model_config = ConfigDict(extra="forbid") + + text: str = Field(min_length=1, max_length=300) + cite: str | None = None +``` + +and add to `ProgramPlan` after `test_milestones`: + +```python + advice: list[Guidance] = Field(default_factory=list) + rationale: list[Guidance] = Field(default_factory=list) +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/memory/ -q` — Expected: PASS (old plan.yaml files load: both fields default) + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/memory/schemas.py tests/memory/test_schemas_progression.py +git commit -m "Add Guidance advice/rationale to ProgramPlan + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 12: `resolve_citations` + `plan_citation_ids` + +**Files:** +- Modify: `src/performance_agent/evidence/citations.py` +- Modify: `src/performance_agent/programs/render.py` (add `plan_citation_ids` only, rendering comes in Task 13) +- Test: `tests/evidence/test_citations_resolution.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/evidence/test_citations_resolution.py`: + +```python +"""Corpus-id resolution for deliverable bibliographies.""" + +import pytest + +from performance_agent.evidence.citations import resolve_citations +from performance_agent.evidence.corpus import load_corpus + + +def test_resolves_known_ids_with_stars(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + entry = load_corpus()[0] + resolved = resolve_citations([entry.id]) + citation = resolved[entry.id] + assert entry.title in citation.citation + assert "★" in citation.stars + + +def test_unknown_id_is_a_hard_error(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + with pytest.raises(ValueError, match="phantom-id"): + resolve_citations(["phantom-id"]) + + +def test_plan_citation_ids_orders_and_dedupes(): + from datetime import date + + from performance_agent.memory.schemas import ( + ExerciseBlock, + Fallbacks, + Guidance, + Mesocycle, + ProgramPlan, + SessionPlan, + WeekPlan, + ) + from performance_agent.programs.render import plan_citation_ids + + def block(cite): + return ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8", + load_kg=80, + progression_rule="hold", + cite=cite, + ) + + plan = ProgramPlan( + version=1, + goal_id="g", + created_on=date(2026, 7, 17), + advice=[Guidance(text="Creatine.", cite="id-a")], + rationale=[Guidance(text="Volume.", cite="id-b"), Guidance(text="Judgment.")], + mesocycles=[ + Mesocycle( + index=1, + phase="accumulation", + weeks=[ + WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="a", + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[block("id-c"), block("id-a")], + fallbacks=Fallbacks( + low_readiness="halve", + short_on_time="cut", + missing_equipment="dumbbells", + ), + ) + ], + ) + ], + ) + ], + ) + assert plan_citation_ids(plan) == ["id-a", "id-b", "id-c"] +``` + +- [ ] **Step 2: Run** — `uv run pytest tests/evidence/test_citations_resolution.py -q` — Expected: FAIL (`ImportError: resolve_citations`) + +- [ ] **Step 3: Implement** + +In `src/performance_agent/evidence/citations.py`, add imports for `dataclass` and the corpus loader, then append: + +```python +from dataclasses import dataclass + +from performance_agent.evidence.corpus import load_corpus +from performance_agent.evidence.schemas import STARS +``` + +(merge with the file's existing imports — `STARS` and `format_citation` may already be imported/defined there; keep one import of each) + +```python +@dataclass(frozen=True) +class ResolvedCitation: + """A corpus id rendered for a deliverable bibliography.""" + + citation: str + stars: str + doi: str | None + pmid: str | None + + +def resolve_citations(ids: "Iterable[str]") -> dict[str, ResolvedCitation]: + """Resolve corpus ids to formatted citations; unknown ids are a hard error. + + This is the render-side anti-fabrication lock for structured plans: a plan + whose advice/rationale/blocks cite an id that is not in the corpus refuses + to save. + """ + entries = {entry.id: entry for entry in load_corpus()} + wanted = list(dict.fromkeys(ids)) + unknown = [cid for cid in wanted if cid not in entries] + if unknown: + msg = f"citation ids not in the evidence corpus: {unknown}" + raise ValueError(msg) + return { + cid: ResolvedCitation( + citation=format_citation(entries[cid]), + stars=STARS[entries[cid].evidence_level], + doi=entries[cid].doi, + pmid=entries[cid].pmid, + ) + for cid in wanted + } +``` + +(add `from collections.abc import Iterable` to the imports; adjust the annotation to `Iterable[str]` unquoted) + +In `src/performance_agent/programs/render.py`, append: + +```python +def plan_citation_ids(plan: ProgramPlan) -> list[str]: + """Every corpus id the plan cites, in order of first appearance, deduplicated. + + Order: advice, then rationale, then blocks in program order — this is the + [n] numbering of the HTML page and the Sources section. + """ + ids: list[str] = [] + seen: set[str] = set() + + def add(cite: str | None) -> None: + if cite and cite not in seen: + seen.add(cite) + ids.append(cite) + + for guidance in (*plan.advice, *plan.rationale): + add(guidance.cite) + for meso in plan.mesocycles: + for week in meso.weeks: + for session in week.sessions: + for block in session.blocks: + add(block.cite) + return ids +``` + +- [ ] **Step 4: Run** — `uv run pytest tests/evidence/ tests/programs/ -q` — Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/performance_agent/evidence/citations.py src/performance_agent/programs/render.py tests/evidence/test_citations_resolution.py +git commit -m "Resolve plan citation ids against the corpus + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 13: Render guidance + bibliography (markdown and HTML) + +**Files:** +- Modify: `src/performance_agent/programs/render.py` +- Modify: `src/performance_agent/programs/render_html.py` +- Test: `tests/programs/test_render.py`, `tests/programs/test_render_html.py` (append) + +- [ ] **Step 1: Write the failing tests** (append to `tests/programs/test_render_html.py`; reuse that file's existing plan fixture/helpers if one exists, otherwise build the same minimal plan as in Task 12's test with `advice=[Guidance(text="Creatine 5 g/day.", cite="id-a")]`, one block citing `"id-a"`, and `citations={"id-a": ResolvedCitation(citation="Kreider et al. (2017). ISSN position stand.", stars="★★★★★", doi="10.1186/s12970-017-0173-z", pmid=None)}` — import `ResolvedCitation` from `performance_agent.evidence.citations`): + +```python +def test_html_renders_banner_markers_and_bibliography(): + page = render_program_html(_guidance_plan(), locale="en", citations=_CITATIONS) + assert "Advice" in page and "Creatine 5 g/day." in page + assert "[1]" in page # marker on the advice line and the citing block + assert "Sources" in page and "★★★★★" in page + assert "https://doi.org/10.1186/s12970-017-0173-z" in page + + +def test_html_without_guidance_or_citations_is_unchanged_shape(): + page = render_program_html(_bare_plan(), locale="en") + assert "Sources" not in page and "Advice" not in page + + +def test_html_localizes_banner_titles_in_french(): + page = render_program_html(_guidance_plan(), locale="fr", citations=_CITATIONS) + assert "Conseils" in page and "Pourquoi ce programme" in page +``` + +And append to `tests/programs/test_render.py`: + +```python +def test_markdown_renders_guidance_and_sources(): + from performance_agent.evidence.citations import ResolvedCitation + from performance_agent.programs.render import render_program + + citations = { + "id-a": ResolvedCitation( + citation="Kreider et al. (2017). ISSN position stand.", + stars="★★★★★", + doi="10.1186/s12970-017-0173-z", + pmid=None, + ) + } + text = render_program(_guidance_plan(), citations=citations) + assert "## Advice" in text + assert "Creatine 5 g/day. [id-a]" in text + assert "## Sources" in text + assert "DOI: 10.1186/s12970-017-0173-z" in text + + +def test_markdown_without_citations_matches_legacy_output(): + text = render_program(_bare_plan()) + assert "## Sources" not in text and "## Advice" not in text +``` + +(define `_guidance_plan()` / `_bare_plan()` helpers once at the bottom of each test file — same construction as Task 12's test plan, with and without advice/rationale/cites) + +- [ ] **Step 2: Run** — `uv run pytest tests/programs/ -q` — Expected: FAIL (unexpected keyword `citations`) + +- [ ] **Step 3: Implement** + +`src/performance_agent/programs/render.py`: + +1. Add import: `from performance_agent.evidence.citations import ResolvedCitation` and `from collections.abc import Mapping`. +2. Add after `_header_lines`: + +```python +def _guidance_lines(plan: ProgramPlan) -> list[str]: + lines: list[str] = [] + for title, items in (("Advice", plan.advice), ("Why this program", plan.rationale)): + if not items: + continue + lines += ["", f"## {title}"] + for guidance in items: + suffix = f" [{guidance.cite}]" if guidance.cite else "" + lines.append(f"- {guidance.text}{suffix}") + return lines + + +def _sources_lines(plan: ProgramPlan, citations: Mapping[str, ResolvedCitation]) -> list[str]: + ids = [cid for cid in plan_citation_ids(plan) if cid in citations] + if not ids: + return [] + lines = ["", "## Sources"] + for number, cid in enumerate(ids, start=1): + resolved = citations[cid] + lines.append(f"{number}. {resolved.stars} {resolved.citation}") + return lines +``` + +3. Change `render_program` to: + +```python +def render_program( + plan: ProgramPlan, citations: Mapping[str, ResolvedCitation] | None = None +) -> str: + """Render a ProgramPlan to the human markdown view (deterministic). + + citations maps corpus ids to their resolved rendering; when provided the + advice/rationale sections and a final Sources section are emitted (the + Sources DOIs are what the Typst PDF bibliography picks up). + """ + lines = _header_lines(plan) + lines += _guidance_lines(plan) + for meso in plan.mesocycles: + lines += ["", f"## Mesocycle {meso.index} — {meso.phase}"] + for week in meso.weeks: + lines.append("") + lines.extend(_week_lines(week)) + if citations is not None: + lines += _sources_lines(plan, citations) + return "\n".join(lines).strip() + "\n" +``` + +Note `format_citation` already renders `DOI: ` inside `citation`, which is exactly what the PDF's `_citations_for` scans for. + +`src/performance_agent/programs/render_html.py`: + +1. Imports: add `from collections.abc import Mapping` and `from performance_agent.evidence.citations import ResolvedCitation` and extend the render import line to `from performance_agent.programs.render import intensity_label, num_label, plan_citation_ids, volume_label`. +2. Add to every `_LABELS` locale dict: + - en: `"advice": "Advice", "why_program": "Why this program", "sources": "Sources",` + - fr: `"advice": "Conseils", "why_program": "Pourquoi ce programme", "sources": "Sources",` + - es: `"advice": "Consejos", "why_program": "Por qué este programa", "sources": "Fuentes",` +3. Add to `_CSS`: + +```css +.guidance { margin: 0.75rem 0 0; } +.guidance h2 { margin: 0.75rem 0 0.25rem; font-size: 1rem; } +.guidance ul { margin: 0.25rem 0 0 1.1rem; padding: 0; } +.guidance li { margin: 0.2rem 0; } +sup.cite { color: var(--accent); font-weight: 600; } +section.sources ol { padding-left: 1.2rem; } +section.sources li { margin: 0.35rem 0; font-size: 0.9rem; } +section.sources .stars { color: var(--accent); letter-spacing: 0.05em; } +``` + +4. New helpers (after `_header_html`): + +```python +def _marker(numbers: dict[str, int], cite: str | None) -> str: + if cite is None or cite not in numbers: + return "" + return f'[{numbers[cite]}]' + + +def _guidance_html(plan: ProgramPlan, numbers: dict[str, int], locale: str) -> str: + sections = [] + for key, emoji, items in ( + ("advice", "💊", plan.advice), + ("why_program", "🔬", plan.rationale), + ): + if not items: + continue + rows = "".join( + f"
  • {html.escape(g.text)}{_marker(numbers, g.cite)}
  • " for g in items + ) + sections.append(f"

    {emoji} {_t(locale, key)}

      {rows}
    ") + if not sections: + return "" + return f'
    {"".join(sections)}
    ' + + +def _sources_html( + plan: ProgramPlan, + citations: Mapping[str, ResolvedCitation], + numbers: dict[str, int], + locale: str, +) -> str: + ordered = sorted( + (cid for cid in numbers if cid in citations), key=lambda cid: numbers[cid] + ) + if not ordered: + return "" + rows = [] + for cid in ordered: + resolved = citations[cid] + link = ( + f' DOI' + if resolved.doi + else "" + ) + rows.append( + f'
  • {resolved.stars} ' + f"{html.escape(resolved.citation)}{link}
  • " + ) + return ( + f'

    📚 {_t(locale, "sources")}

    ' + f"
      {''.join(rows)}
    " + ) +``` + +5. In `_block_html`, thread `numbers: dict[str, int]` through (`_block_html(session, block, catalog, locale, numbers)`) and change the `

    ` line to: + +```python + f'

    {name}{_marker(numbers, block.cite)} [{block.priority}]

    ', +``` + +Update `_session_html` and `_week_html` signatures to accept and pass `numbers` down (they only forward it). + +6. Change `render_program_html`: + +```python +def render_program_html( + plan: ProgramPlan, + locale: str = "en", + index: ExerciseMediaIndex | None = None, + citations: Mapping[str, ResolvedCitation] | None = None, +) -> str: +``` + +Inside, before the mesocycle loop compute `numbers = {cid: i for i, cid in enumerate(plan_citation_ids(plan), start=1)} if citations else {}`, pass `numbers` to `_week_html`, insert `_guidance_html(plan, numbers, locale)` right after `_header_html(plan, locale)` in the final f-string, and insert `_sources_html(plan, citations or {}, numbers, locale)` between the sections and `credit`. + +- [ ] **Step 4: Run** — `uv run pytest tests/programs/ -q` — Expected: PASS, including the pre-existing golden test (bare plans render byte-identically). + +- [ ] **Step 5: Commit** + +```bash +uv run ruff check src/performance_agent/programs tests/programs +git add src/performance_agent/programs/render.py src/performance_agent/programs/render_html.py tests/programs/ +git commit -m "Render advice, rationale and starred bibliography in program views + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 14: Wire citations through `save_program` + +**Files:** +- Modify: `src/performance_agent/memory/store.py` (`save_program` signature) +- Modify: `src/performance_agent/server/memory_tools.py` (`save_program`, `_write_program_html`) +- Test: `tests/server/test_memory_tools.py` (append) + +- [ ] **Step 1: Write the failing tests** (append to `tests/server/test_memory_tools.py`, reusing that file's existing athlete-dir fixture pattern — it already tests `save_program`; follow its plan-building helper. New tests): + +```python +def test_save_program_rejects_unknown_cite(athlete_dir): + plan = _minimal_plan() # the file's existing helper for a valid one-block plan + plan = plan.model_copy( + update={"advice": [Guidance(text="Creatine 5 g/day.", cite="phantom-id")]} + ) + with pytest.raises(ValueError, match="phantom-id"): + memory_tools.save_program(plan) + + +def test_save_program_with_corpus_cite_renders_sources(athlete_dir): + from performance_agent.evidence.corpus import load_corpus + + real_id = load_corpus()[0].id + plan = _minimal_plan().model_copy( + update={"advice": [Guidance(text="Backed advice.", cite=real_id)]} + ) + result = memory_tools.save_program(plan) + markdown = memory_tools.read_program(result["version"])["markdown"] + assert "## Sources" in markdown + html_page = Path(result["html_path"]).read_text(encoding="utf-8") + assert "Sources" in html_page +``` + +(adapt helper names to what the file actually defines; import `Guidance` and `Path` at the top with the file's imports) + +- [ ] **Step 2: Run** — `uv run pytest tests/server/test_memory_tools.py -q` — Expected: FAIL (no Sources section / no rejection) + +- [ ] **Step 3: Implement** + +`src/performance_agent/memory/store.py` — extend `save_program`: + +```python +def save_program( + base_dir: Path, + plan: ProgramPlan, + reason: str | None = None, + today: date | None = None, + citations: "Mapping[str, ResolvedCitation] | None" = None, +) -> tuple[Path, int]: +``` + +with imports `from collections.abc import Mapping` and `from performance_agent.evidence.citations import ResolvedCitation`, and the render line becomes: + +```python + content = ( + "---\n" + + _to_yaml(frontmatter) + + "---\n\n" + + render_program(stamped, citations=citations).strip() + + "\n" + ) +``` + +(docstring: add `citations maps the plan's corpus ids to their resolved rendering; the server resolves them — None keeps the legacy citation-less rendering for direct store users.`) + +`src/performance_agent/server/memory_tools.py`: + +1. Imports: `from performance_agent.evidence.citations import ResolvedCitation, resolve_citations` and `from performance_agent.programs.render import plan_citation_ids` and `from collections.abc import Mapping`. +2. `_write_program_html` gains a `citations` parameter: + +```python +def _write_program_html( + base: Path, + md_path: Path, + version: int, + citations: Mapping[str, ResolvedCitation] | None = None, +) -> str | None: +``` + +and the render call becomes `render_program_html(program.plan, locale=locale, index=index, citations=citations)`. + +3. `save_program` resolves before writing anything: + +```python +def save_program(plan: ProgramPlan, reason: str | None = None) -> ProgramSaved: + base = resolve_athlete_dir() + citations = resolve_citations(plan_citation_ids(plan)) + path, version = store.save_program(base, plan, reason, citations=citations) + html_path = _write_program_html(base, path, version, citations) + return ProgramSaved(path=str(path), version=version, html_path=html_path) +``` + +and extend its docstring with: `Every cite on advice/rationale/blocks must be a real corpus id — an unknown id aborts the save before anything is written (anti-fabrication).` + +- [ ] **Step 4: Run** — `uv run pytest tests/server/ tests/memory/ tests/programs/ -q` — Expected: PASS + +- [ ] **Step 5: Commit + phase gate** + +```bash +uv run pytest -q && uv run ruff check src tests && uv run ty check +git add src/performance_agent/memory/store.py src/performance_agent/server/memory_tools.py tests/server/test_memory_tools.py +git commit -m "Resolve and render plan citations at save time + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Phase 4 — Skills + +### Task 15: New skill `next-week-loads` + +**Files:** +- Create: `skills/next-week-loads/SKILL.md` + +- [ ] **Step 1: Write the skill** + +```markdown +--- +name: next-week-loads +description: Use when the athlete has logged their training week (or the loads_review + due action fires) and wants next week's working weights. Presents the engine's + deterministic suggestions block by block and never modifies the program. +tools: [read_athlete, get_time_context, read_program, read_sessions, + suggest_next_week_loads] +--- + +# Next week's loads + +The weekly review ritual: the athlete finished their training week, the logs are +in — hand them next week's numbers. All math is engine math: this skill quotes +`suggest_next_week_loads`, it never invents a load and it NEVER versions the +program (structure changes belong to program-adaptation). + +## Ritual + +1. Open with `read_athlete` + `get_time_context` (quote its dates, never compute + your own). `read_program` for the active version. +2. Call `suggest_next_week_loads`. Present the verdicts as a compact per-session + table in the athlete's locale: exercise, what they did, next load, and the + engine's rationale_key rendered as a sentence ("all sets at the top of the + range — +2.5 kg"). +3. Flags are conversations, not errors: + - `no_rule` — the block predates structured progression: agree the next load + with the athlete conversationally, from their logs (`read_sessions` for + context), and say plainly it is coaching judgment. + - `failed_sets` / `no_logged_sets` / `no_rir_logged` / `no_e1rm` / + `ambiguous_reps` — say what is missing and what would unlock the number. + - `clamped` — the autoregulated jump was capped at ±10% for safety; say so. + - `no_matched_week` — the logs don't map to any program week; ask what + actually happened this week. +4. Repeated `failed_sets` on the same exercise, pain mentions, or a stalled lift + are NOT solved here: name the signal and route to program-adaptation (and to + program-watch when the athlete wants the full audit). +5. `last_week` flag: the program just ended — route to training-checkin / + program-planning for what comes next. + +Numbers are quoted, never negotiated upward past the engine's suggestion; the +athlete can always choose LESS than suggested. +``` + +- [ ] **Step 2: Verify structure** — Run: `uv run pytest tests/skills/ -q` — Expected: FAIL on `test_all_expected_skills_exist` (new skill not in EXPECTED_SKILLS — fixed in Task 17; the OTHER skills tests must not fail). If `test_tool_references` fails here, a declared tool name is wrong — fix the frontmatter now. + +- [ ] **Step 3: Commit** + +```bash +git add skills/next-week-loads/SKILL.md +git commit -m "Add next-week-loads skill + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 16: New skill `program-watch` + +**Files:** +- Create: `skills/program-watch/SKILL.md` + +- [ ] **Step 1: Write the skill** + +```markdown +--- +name: program-watch +description: Use every two weeks, at each mesocycle boundary, or on demand to audit + whether the running program is working exercise by exercise. Produces a keep / + watch / substitution-candidate verdict per exercise and never edits anything. +tools: [read_athlete, get_time_context, read_program, read_sessions, read_checkins, + compare_prescribed_actual, estimate_1rm, compute_weekly_loads, + compute_monotony_strain, score_exercises, get_citation, save_watch_report] +--- + +# Program watch + +The running program's auditor. Data only, per exercise — the question is never +"is the athlete tired" (training-checkin owns that) but "is THIS exercise doing +its job". Designed to run as a subagent launched by performance-coach or +training-checkin: audit silently, come back with a short report. + +## Signals, per exercise + +1. Open with `read_athlete` + `get_time_context` (quote its dates), then + `read_program`, `read_sessions`, `read_checkins` over the current mesocycle. +2. Trajectory — best sets per exercise through `estimate_1rm`: is the estimated + 1RM (or pace at heart rate, via compare_prescribed_actual, for endurance + blocks) moving the way the block intends? +3. Adherence — an exercise systematically skipped or cut short is a signal about + THAT exercise (friction, equipment, quiet pain), not laziness. +4. Pain — pain_flags and session notes that recur around one movement. +5. Chronic gap — compare_prescribed_actual: prescribed vs done, week after week. +6. Load shape — compute_weekly_loads + compute_monotony_strain when the pattern + suggests a structural problem (everything hard, nothing varied). + +## Verdict and report + +Per audited exercise: **keep** (working — say why in one line), **watch** (name +the signal and the check for next time), or **substitution candidate** (name the +signal, propose 1-2 replacements via score_exercises, cite corpus evidence with +get_citation when one backs the swap — otherwise label it coaching judgment). + +Write the report with save_watch_report (goal_id from the program; v2+ reason = +the trigger: "biweekly watch", "mesocycle boundary", "athlete request"). Keep it +short: verdicts first, data behind them after. + +## Hard boundary + +This skill NEVER edits the program, never prescribes, never substitutes in +place. Substitution candidates route to program-adaptation, which owns the +diagnosis conversation, the versioned save and the program-review gate. At a +mesocycle boundary, pair with deep-research's incremental watch: this report +says what to watch, the research says what the science says. +``` + +- [ ] **Step 2: Verify** — Run: `uv run pytest tests/skills/test_tool_references.py -q` — Expected: only `test_all_expected_skills_exist`-style failures from test_structure (fixed next task); tool references must PASS for this file. + +- [ ] **Step 3: Commit** + +```bash +git add skills/program-watch/SKILL.md +git commit -m "Add program-watch skill + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 17: Edit the seven existing skills + test invariants + +**Files:** +- Modify: `skills/deep-research/SKILL.md`, `skills/program-adaptation/SKILL.md`, `skills/training-checkin/SKILL.md`, `skills/performance-coach/SKILL.md`, `skills/athlete-onboarding/SKILL.md`, `skills/program-optimization/SKILL.md`, `skills/program-review/SKILL.md` +- Modify: `tests/skills/test_structure.py` + +- [ ] **Step 1: Update `tests/skills/test_structure.py` first (failing invariants drive the edits)** + +Add to `EXPECTED_SKILLS`: `"next-week-loads", "program-watch",` + +Append two protocol tests: + +```python +def test_next_week_loads_skill_protocol(skills): + loads = next(s for s in skills if s.frontmatter["name"] == "next-week-loads") + body = loads.body.casefold() + for needle in ( + "suggest_next_week_loads", + "read_program", + "never versions the program", + "no_rule", + "failed_sets", + "clamped", + "program-adaptation", + "coaching judgment", + ): + assert needle in body, f"next-week-loads skill lost: {needle}" + + +def test_program_watch_skill_protocol(skills): + watch = next(s for s in skills if s.frontmatter["name"] == "program-watch") + body = watch.body.casefold() + for needle in ( + "save_watch_report", + "compare_prescribed_actual", + "estimate_1rm", + "score_exercises", + "keep", + "watch", + "substitution candidate", + "never edits the program", + "program-adaptation", + "subagent", + ): + assert needle in body, f"program-watch skill lost: {needle}" +``` + +And extend three existing protocol tests with new needles (append to each tuple): +- `test_research_skill_protocol`: `"list_athlete_documents"`, `"mini-wave"`, `"year_from"` +- `test_adaptation_skill_protocol`: `"mini-wave"`, `"read_research_dossier"`, `"adapt first"` +- `test_checkin_skill_protocol`: `"list_athlete_documents"`, `"mesocycle boundary"` + +Run: `uv run pytest tests/skills/ -q` — Expected: FAIL on exactly these invariants (that's the worklist). + +- [ ] **Step 2: `skills/deep-research/SKILL.md`** + +Frontmatter: add `list_athlete_documents, mark_document_processed, verify_reference` to `tools` (keep existing entries; `verify_reference` is already declared — do not duplicate). + +Insert as the FIRST protocol section (before the current facet-decomposition section): + +```markdown +## 0. The athlete's own documents — always first + +Before any online search, call `list_athlete_documents`. For every `new` or +`modified` file: read it (the tool hands you the absolute path; paginate large +PDFs). Then route it into exactly one lane and record it with +`mark_document_processed`: + +- **evidence** — ONLY when the document carries a DOI/PMID/ISBN that resolves + via `verify_reference`. Save it with `save_evidence` under the registry's + canonical title (you read the full text — conclusions may be richer than an + abstract-only entry), then mark with the corpus ids in `evidence_ids`. +- **context** — everything else (physio reports, lab results, past programs, + unverifiable PDFs): summarize what matters for coaching into `summary` and + `key_points`. It informs personalization and the facets below, but it is + NEVER cited as science in any deliverable. +- **unreadable** — corrupt or unopenable; mark it so you stop retrying. + +What the documents claim shapes the facets: a dropped study on a facet joins +that facet's evidence; a physio report adds a constraint facet. +``` + +Append at the end of the file: + +```markdown +## Mini-waves and the incremental watch + +A **mini-wave** is this protocol scoped to ONE question: corpus first, then 2-3 +live queries in English + the athlete's locale (+1 language if thin), same +verification and save rules, folded into the dossier as v+1 whose reason names +the trigger, with a "what changed vs v{N}" section. Program-adaptation runs +mini-waves for substantive triggers; run one directly when the athlete drops a +document or asks a question that touches one facet. + +The **incremental watch** (each mesocycle boundary, routed by training-checkin): +replay the dossier facets' queries with `year_from` set to the current dossier's +year — thin facets first. Something new → dossier v+1; nothing → no new version, +say so in one line. +``` + +- [ ] **Step 3: `skills/program-adaptation/SKILL.md`** + +Frontmatter: add `read_research_dossier, save_research_dossier` to `tools`. + +Insert a new section after the diagnosis section (before citation repair): + +```markdown +## Research refresh — the mini-wave + +**Adapt first, research second**: tonight's session never waits for literature. +When the trigger is substantive — a confirmed plateau, recurring pain, a +calendar or method change — run a mini-wave AFTER the immediate fix is agreed: + +1. `read_research_dossier` for what the dossier already says on the question. +2. One question, 2-3 live queries (English + locale, +1 language if thin), the + deep-research verification and save rules unchanged. +3. Fold what you learned into the dossier with `save_research_dossier` (v+1, + reason = the trigger, with a "what changed" section). +4. If the finding contradicts the ACTIVE program, propose the sourced change to + the athlete through this skill's normal versioned flow — never edit silently. +``` + +- [ ] **Step 4: `skills/training-checkin/SKILL.md`** + +Frontmatter: add `list_athlete_documents` to `tools`. + +Add to the opening ritual (where the skill lists its opening calls, right after the due-actions step): + +```markdown +- `list_athlete_documents`: new or modified files in the drop folder are part of + the check-in — acknowledge them, and route to deep-research §0 to process + them (a physio report may change today's plan). +``` + +Add a section before the routing/trigger section: + +```markdown +## Mesocycle boundary duties + +When this check-in crosses into a new mesocycle (compare today against the +program's week boundaries): (1) route to deep-research's incremental watch — +replay the dossier facets with year_from = the dossier's year, thin facets +first; (2) route to program-watch for the per-exercise audit. The +loads_review and program_watch due actions surface both when overdue — treat +them like any other due action: open with them. +``` + +- [ ] **Step 5: `skills/performance-coach/SKILL.md`** + +Frontmatter: add `list_athlete_documents` to `tools`. + +In the opening ritual (after the `list_due_actions` step), add: + +```markdown +- `list_athlete_documents` — dropped files are messages: acknowledge new ones + and have them processed (deep-research §0) before they go stale. +``` + +In the routing table/section, add two rows/lines: + +```markdown +- Training week logged, athlete wants next week's weights, or the loads_review + action fires → **next-week-loads**. +- Two weeks since the last audit, a mesocycle boundary, or "is this program + still right?" → **program-watch** (run it as a subagent; bring back verdicts). +``` + +- [ ] **Step 6: `skills/athlete-onboarding/SKILL.md`** + +Add one line to the closing/wrap-up section (no tool changes — write_profile creates the folder): + +```markdown +Tell the athlete about their `documentation/` folder (created with the +profile): studies, physio or medical reports, lab results, past programs +dropped there are picked up automatically at the next conversation. +``` + +- [ ] **Step 7: `skills/program-optimization/SKILL.md`** + +In the block-construction section (where per-block fields are specified), add: + +```markdown +Every load-bearing block (load_kg, pct_1rm or rir intensity) carries a +STRUCTURED `progression` rule alongside the prose `progression_rule`: kind +`double` (rep_min/rep_max/increment_kg), `linear_load` (increment_kg), +`rir_target` (target_rir), `from_pct` (percent-planned weeks), or `none` +(pace/technique blocks). The prose is the human rendering of the structured +rule — write both from the same decision. This is what powers the weekly +loads review; a block left unstructured surfaces as `no_rule` every week. +``` + +In the section where the program document is assembled (before save_program), add: + +```markdown +Fill the plan's `advice` (nutrition, supplements, recovery — the frame's +numbers from read_nutrition_frame become athlete-facing lines here) and +`rationale` ("why this program" — the 3-5 decisions that shaped it). Every +line either carries a corpus `cite` (rendered with stars in the HTML header +and bibliography) or reads as coaching judgment. save_program refuses unknown +cite ids — never invent one. +``` + +- [ ] **Step 8: `skills/program-review/SKILL.md`** + +Add to the deterministic compliance checklist: + +```markdown +- Structured progression: every block prescribing load_kg, pct_1rm or rir has a + `progression` rule whose kind matches the prescription (a pct_1rm block with + kind=double is an objection); the prose progression_rule tells the same story. +- Guidance honesty: every advice/rationale line either cites a corpus id + (verify each with get_citation) or is phrased as coaching judgment; dosage + claims without a cite are an objection. +``` + +- [ ] **Step 9: Run the full skills suite** — `uv run pytest tests/skills/ -q` — Expected: PASS (structure, tool references, protocols, no fabricated refs). If `test_bodies_do_not_reference_undeclared_tools` fails, a body mentions a tool missing from its frontmatter — add it to `tools`. + +- [ ] **Step 10: Commit** + +```bash +git add skills/ tests/skills/test_structure.py +git commit -m "Wire documents, mini-waves and weekly follow-up into the skills + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 18: README + final gate + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update README** — locate the tool count (search for `93`) and update to **97**; in the feature list add four bullets in the repo's existing bullet style: + +```markdown +- **Athlete document drop folder** — drop studies, physio reports or past + programs into `documentation/`; verified studies join the evidence corpus + (full text read), everything else informs coaching as context, never faked + as science. +- **Research that stays alive** — targeted mini-waves on plateaus, injuries and + athlete questions; an incremental literature watch at every mesocycle + boundary (`year_from` delta queries), all folded into the versioned dossier. +- **Weekly loads review** — structured per-block progression rules computed by + the engine (`suggest_next_week_loads`): next week's exact weights from this + week's logs, flags instead of guesses. +- **Program watch** — a biweekly per-exercise audit (keep / watch / substitute + candidate) written as a versioned report; substitutions go through + program-adaptation, never silently. +- **Science on the gym page** — the offline program HTML opens with sourced + advice and "why this program" lines, `[n]` markers on blocks, and a starred + bibliography. +``` + +- [ ] **Step 2: Full suite + linters** — Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: everything green, zero warnings. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "Document the living-evidence and weekly follow-up features + +Co-Authored-By: Claude Fable 5 " +``` + +- [ ] **Step 4: NOT in this plan** — version bump, CHANGELOG, PyPI release, and the pre-competition spec are separate work. + +--- + +## Plan self-review notes + +- Spec coverage: §3 → Tasks 1-4; §4 → Tasks 15-17 (skills only, as specified); §5 → Tasks 5-10; §6 → Tasks 11-14; §7 (errors) → distributed into each module's tests; §8 (testing) → every task is test-first; §9 out-of-scope respected (no PDF extraction, no push, no auto-deload). +- Type consistency: `ScanResult`/`DocumentView`/`ProcessedView` (Tasks 1, 3), `SetActual`/`LoadSuggestion` (Tasks 6, 7), `WeeklyLoadsView`/`BlockSuggestionView` (Tasks 7, 9), `ResolvedCitation` (Tasks 12, 13, 14), `VersionedDocSaved` reused from memory_tools (Task 9) — names match across tasks. +- Known judgment calls an executor may hit: exact insertion anchors inside the seven SKILL.md files are described relationally (the files' section names are stable but not line-numbered here); ruff may reformat long lines — run `uv run ruff format` on touched files before committing. + + From 1a2757ffae191fa7c7cf984943038979314c3818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:46:47 +0200 Subject: [PATCH 03/22] Add athlete documentation folder registry and scan Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/documents.py | 230 ++++++++++++++++++++++ tests/memory/test_documents.py | 158 +++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 src/performance_agent/memory/documents.py create mode 100644 tests/memory/test_documents.py diff --git a/src/performance_agent/memory/documents.py b/src/performance_agent/memory/documents.py new file mode 100644 index 0000000..7853785 --- /dev/null +++ b/src/performance_agent/memory/documents.py @@ -0,0 +1,230 @@ +"""Athlete-dropped documents: folder bootstrap, registry, scan, mark. + +The athlete drops files (studies, physio reports, past programs) into +documentation/; the agent detects new/changed files by content hash and records +what it did with each one. Only `processed` and `unreadable` are stored — +`new`, `modified` and `removed` are derived at scan time. The registry is +reconstructible by design: a deleted or corrupt index.yaml simply makes files +show up as new again (corpus entries are verified independently and survive). +""" + +import hashlib +import os +from datetime import date +from pathlib import Path +from typing import Literal, Self, TypedDict + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +DOCUMENTATION_DIR = "documentation" +REGISTRY_FILE = "index.yaml" +README_FILE = "README.md" +_EXCLUDED_FILES = {REGISTRY_FILE, README_FILE} + +_README_CONTENT = """\ +# Documentation + +EN — Drop documents for your coach here: published studies (PDF), physio or +medical reports you want considered, lab test results, past training programs. +New and changed files are picked up automatically. A study whose DOI/PMID can +be verified joins the evidence corpus; everything else informs your coaching +as context but is never presented as science. + +FR — Déposez ici les documents pour votre coach : études publiées (PDF), +bilans kiné/médicaux à partager, résultats de tests, anciens programmes. +Les fichiers nouveaux ou modifiés sont détectés automatiquement. Une étude +dont le DOI/PMID est vérifiable rejoint le corpus scientifique ; tout le +reste nourrit le coaching comme contexte, jamais présenté comme de la science. +""" + +Lane = Literal["evidence", "context", "unreadable"] + + +class DocumentRecord(BaseModel): + """One processed (or unreadable) dropped file, keyed by filename.""" + + model_config = ConfigDict(extra="forbid") + + filename: str = Field(min_length=1) + sha256: str = Field(min_length=64, max_length=64) + size_bytes: int = Field(ge=0) + first_seen: date + processed_on: date + lane: Lane + summary: str | None = None + key_points: list[str] = Field(default_factory=list) + evidence_ids: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _summary_required_unless_unreadable(self) -> Self: + if self.lane != "unreadable" and not self.summary: + msg = f"{self.filename}: a summary is required for lane {self.lane!r}" + raise ValueError(msg) + return self + + +class DocumentRegistry(BaseModel): + """The whole documentation/index.yaml file.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] = 1 + documents: list[DocumentRecord] = Field(default_factory=list) + + +class DocumentView(TypedDict): + """A file awaiting processing (new or modified).""" + + filename: str + path: str + size_bytes: int + + +class ProcessedView(TypedDict): + """A file the agent already handled, with what it retained.""" + + filename: str + path: str + lane: str + summary: str | None + + +class ScanResult(TypedDict): + """Derived folder state: only processed/unreadable are stored on disk.""" + + path: str + new: list[DocumentView] + modified: list[DocumentView] + processed: list[ProcessedView] + removed: list[str] + unreadable: list[ProcessedView] + + +def documentation_dir(base_dir: Path) -> Path: + """Return the documentation folder path (never creates it).""" + return base_dir / DOCUMENTATION_DIR + + +def ensure_documentation_dir(base_dir: Path) -> Path: + """Create the folder and its README when missing; never overwrites.""" + doc_dir = documentation_dir(base_dir) + doc_dir.mkdir(parents=True, exist_ok=True) + readme = doc_dir / README_FILE + if not readme.exists(): + readme.write_text(_README_CONTENT, encoding="utf-8") + return doc_dir + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + except OSError: + tmp.unlink(missing_ok=True) + raise + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_registry(base_dir: Path) -> DocumentRegistry: + """Load the registry; a missing or corrupt file yields an empty registry.""" + path = documentation_dir(base_dir) / REGISTRY_FILE + if not path.exists(): + return DocumentRegistry() + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + return DocumentRegistry.model_validate(raw or {}) + except (yaml.YAMLError, ValidationError): + return DocumentRegistry() + + +def _save_registry(base_dir: Path, registry: DocumentRegistry) -> None: + path = documentation_dir(base_dir) / REGISTRY_FILE + _atomic_write( + path, + yaml.safe_dump(registry.model_dump(mode="json"), sort_keys=False, allow_unicode=True), + ) + + +def scan_documents(base_dir: Path) -> ScanResult: + """Compare the folder against the registry; derives new/modified/removed.""" + doc_dir = ensure_documentation_dir(base_dir) + registry = load_registry(base_dir) + records = {record.filename: record for record in registry.documents} + present = { + path.name: path + for path in sorted(doc_dir.iterdir()) + if path.is_file() and path.name not in _EXCLUDED_FILES + } + result = ScanResult( + path=str(doc_dir), new=[], modified=[], processed=[], removed=[], unreadable=[] + ) + for name, path in present.items(): + view = DocumentView(filename=name, path=str(path), size_bytes=path.stat().st_size) + record = records.get(name) + if record is None: + result["new"].append(view) + elif record.sha256 != _sha256(path): + result["modified"].append(view) + else: + processed = ProcessedView( + filename=name, path=str(path), lane=record.lane, summary=record.summary + ) + if record.lane == "unreadable": + result["unreadable"].append(processed) + else: + result["processed"].append(processed) + result["removed"] = sorted(set(records) - set(present)) + return result + + +def mark_processed( # noqa: PLR0913 -- one keyword per registry field, all named + base_dir: Path, + filename: str, + *, + lane: Lane, + summary: str | None = None, + key_points: list[str] | None = None, + evidence_ids: list[str] | None = None, + known_evidence_ids: set[str], + today: date | None = None, +) -> DocumentRecord: + """Record the outcome for one file; replaces any previous record. + + known_evidence_ids must cover the whole corpus (packaged + personal); the + caller builds it so this module never depends on the evidence package. + """ + path = documentation_dir(base_dir) / filename + if not path.is_file(): + msg = f"{filename}: no such file in {documentation_dir(base_dir)}" + raise ValueError(msg) + unknown = [eid for eid in (evidence_ids or []) if eid not in known_evidence_ids] + if unknown: + msg = f"{filename}: evidence_ids not in the corpus: {unknown}" + raise ValueError(msg) + registry = load_registry(base_dir) + previous = {record.filename: record for record in registry.documents} + current = today or date.today() + record = DocumentRecord( + filename=filename, + sha256=_sha256(path), + size_bytes=path.stat().st_size, + first_seen=previous[filename].first_seen if filename in previous else current, + processed_on=current, + lane=lane, + summary=summary, + key_points=key_points or [], + evidence_ids=evidence_ids or [], + ) + kept = [r for r in registry.documents if r.filename != filename] + _save_registry(base_dir, DocumentRegistry(documents=[*kept, record])) + return record diff --git a/tests/memory/test_documents.py b/tests/memory/test_documents.py new file mode 100644 index 0000000..604df17 --- /dev/null +++ b/tests/memory/test_documents.py @@ -0,0 +1,158 @@ +"""Documentation folder: registry, scan states, mark validation.""" + +from datetime import date + +import pytest + +from performance_agent.memory.documents import ( + DOCUMENTATION_DIR, + README_FILE, + REGISTRY_FILE, + ensure_documentation_dir, + load_registry, + mark_processed, + scan_documents, +) + +TODAY = date(2026, 7, 17) + + +def _drop(base, name, content=b"pdf-bytes"): + doc_dir = base / DOCUMENTATION_DIR + doc_dir.mkdir(parents=True, exist_ok=True) + (doc_dir / name).write_bytes(content) + + +def test_ensure_creates_folder_and_readme(tmp_path): + path = ensure_documentation_dir(tmp_path) + assert path == tmp_path / DOCUMENTATION_DIR + assert path.is_dir() + readme = path / README_FILE + assert readme.exists() + assert "documentation" in readme.read_text(encoding="utf-8").casefold() + + +def test_ensure_is_idempotent_and_keeps_readme_edits(tmp_path): + readme = ensure_documentation_dir(tmp_path) / README_FILE + readme.write_text("custom", encoding="utf-8") + ensure_documentation_dir(tmp_path) + assert readme.read_text(encoding="utf-8") == "custom" + + +def test_scan_reports_new_files_and_excludes_registry_and_readme(tmp_path): + ensure_documentation_dir(tmp_path) + _drop(tmp_path, "study.pdf") + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["new"]] == ["study.pdf"] + assert result["modified"] == [] + assert result["processed"] == [] + assert result["removed"] == [] + assert result["unreadable"] == [] + + +def test_mark_then_scan_reports_processed_with_summary(tmp_path): + _drop(tmp_path, "study.pdf") + record = mark_processed( + tmp_path, + "study.pdf", + lane="evidence", + summary="Creatine meta-analysis.", + evidence_ids=["creatine-2017"], + known_evidence_ids={"creatine-2017"}, + today=TODAY, + ) + assert record.lane == "evidence" + result = scan_documents(tmp_path) + assert result["new"] == [] + assert result["processed"][0]["summary"] == "Creatine meta-analysis." + + +def test_modified_file_is_reported_for_reprocessing(tmp_path): + _drop(tmp_path, "study.pdf") + mark_processed( + tmp_path, + "study.pdf", + lane="context", + summary="v1", + known_evidence_ids=set(), + today=TODAY, + ) + _drop(tmp_path, "study.pdf", content=b"changed-bytes") + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["modified"]] == ["study.pdf"] + + +def test_removed_is_derived_not_stored(tmp_path): + _drop(tmp_path, "study.pdf") + mark_processed( + tmp_path, + "study.pdf", + lane="context", + summary="s", + known_evidence_ids=set(), + today=TODAY, + ) + (tmp_path / DOCUMENTATION_DIR / "study.pdf").unlink() + result = scan_documents(tmp_path) + assert result["removed"] == ["study.pdf"] + stored = load_registry(tmp_path) + assert [r.filename for r in stored.documents] == ["study.pdf"] + + +def test_unreadable_lane_needs_no_summary(tmp_path): + _drop(tmp_path, "corrupt.pdf") + record = mark_processed( + tmp_path, + "corrupt.pdf", + lane="unreadable", + known_evidence_ids=set(), + today=TODAY, + ) + assert record.summary is None + assert scan_documents(tmp_path)["unreadable"][0]["filename"] == "corrupt.pdf" + + +def test_mark_unknown_file_fails(tmp_path): + ensure_documentation_dir(tmp_path) + with pytest.raises(ValueError, match=r"ghost\.pdf"): + mark_processed( + tmp_path, + "ghost.pdf", + lane="context", + summary="s", + known_evidence_ids=set(), + today=TODAY, + ) + + +def test_mark_evidence_or_context_requires_summary(tmp_path): + _drop(tmp_path, "study.pdf") + with pytest.raises(ValueError, match="summary"): + mark_processed( + tmp_path, + "study.pdf", + lane="evidence", + known_evidence_ids=set(), + today=TODAY, + ) + + +def test_mark_rejects_unknown_evidence_id(tmp_path): + _drop(tmp_path, "study.pdf") + with pytest.raises(ValueError, match="phantom-id"): + mark_processed( + tmp_path, + "study.pdf", + lane="evidence", + summary="s", + evidence_ids=["phantom-id"], + known_evidence_ids={"other"}, + today=TODAY, + ) + + +def test_corrupt_registry_is_rebuilt_empty(tmp_path): + _drop(tmp_path, "study.pdf") + (tmp_path / DOCUMENTATION_DIR / REGISTRY_FILE).write_text("not: [valid", encoding="utf-8") + result = scan_documents(tmp_path) + assert [item["filename"] for item in result["new"]] == ["study.pdf"] From 2504a54180299a5c5a92fcc8a3eaa23ad686ab41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:48:44 +0200 Subject: [PATCH 04/22] Add documentation drop folder tools and write_profile bootstrap list_athlete_documents + mark_document_processed MCP tools, folder creation from write_profile, module-level test imports. Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/store.py | 7 +- src/performance_agent/server/app.py | 2 + .../server/document_tools.py | 78 +++++++++++++++++++ tests/memory/test_documents.py | 7 ++ tests/server/test_document_tools.py | 39 ++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/performance_agent/server/document_tools.py create mode 100644 tests/server/test_document_tools.py diff --git a/src/performance_agent/memory/store.py b/src/performance_agent/memory/store.py index e088b86..63e5d5f 100644 --- a/src/performance_agent/memory/store.py +++ b/src/performance_agent/memory/store.py @@ -13,6 +13,7 @@ import yaml from pydantic import ValidationError +from performance_agent.memory.documents import ensure_documentation_dir from performance_agent.memory.schemas import ( Calendar, CalendarEvent, @@ -101,9 +102,13 @@ def read_profile(base_dir: Path) -> Profile: def write_profile(base_dir: Path, profile: Profile) -> Path: - """Persist the profile as readable YAML; returns the file path.""" + """Persist the profile as readable YAML; returns the file path. + + Also bootstraps the documentation/ drop folder so onboarding creates it. + """ path = base_dir / PROFILE_FILE _atomic_write(path, _to_yaml(profile.model_dump(mode="json"))) + ensure_documentation_dir(base_dir) return path diff --git a/src/performance_agent/server/app.py b/src/performance_agent/server/app.py index acf9203..2f530b7 100644 --- a/src/performance_agent/server/app.py +++ b/src/performance_agent/server/app.py @@ -5,6 +5,7 @@ from performance_agent.exercises.dataset import start_background_sync from performance_agent.server import ( autoregulation_tools, + document_tools, engine_tools, evidence_tools, exercise_tools, @@ -29,6 +30,7 @@ exercise_tools.register(mcp) taper_tools.register(mcp) macro_tools.register(mcp) +document_tools.register(mcp) def main() -> None: diff --git a/src/performance_agent/server/document_tools.py b/src/performance_agent/server/document_tools.py new file mode 100644 index 0000000..d85ab79 --- /dev/null +++ b/src/performance_agent/server/document_tools.py @@ -0,0 +1,78 @@ +"""MCP tools for the athlete documentation drop folder. + +The server hands out paths and bookkeeping only — reading the files (PDFs +included) is the client's job, and saving a verified study goes through the +regular verify_reference/save_evidence pipeline. Lane rule: `evidence` only +when a locator resolved; everything else is `context` (used to personalize, +never cited as science) or `unreadable`. +""" + +from typing import TypedDict + +from mcp.server.fastmcp import FastMCP + +from performance_agent.evidence.corpus import load_corpus +from performance_agent.memory import documents +from performance_agent.memory.documents import Lane, ScanResult +from performance_agent.memory.paths import resolve_athlete_dir + + +class DocumentMarked(TypedDict): + """The stored registry record after marking one file.""" + + filename: str + lane: str + summary: str | None + evidence_ids: list[str] + + +def list_athlete_documents() -> ScanResult: + """Inventory the athlete's documentation/ drop folder (creates it on first call). + + Returns files split into: new (never processed), modified (content changed + since processing — process again), processed (with the stored summary, so + you know what you know without re-reading), removed (registry entry whose + file is gone), unreadable. Each pending item carries its absolute path — + read the file yourself, then record the outcome with + mark_document_processed. Never writes the registry. + """ + return documents.scan_documents(resolve_athlete_dir()) + + +def mark_document_processed( + filename: str, + lane: Lane, + summary: str | None = None, + key_points: list[str] | None = None, + evidence_ids: list[str] | None = None, +) -> DocumentMarked: + """Record what you did with one dropped file (replaces any earlier record). + + lane must follow the hard rule: `evidence` ONLY when a DOI/PMID/ISBN from + the document resolved via verify_reference and the study was saved with + save_evidence (list those corpus ids in evidence_ids — they are validated + against the corpus). Everything else is `context` (summary + key_points + persist and inform coaching, never cited as science) or `unreadable`. + summary is required except for unreadable files. + """ + record = documents.mark_processed( + resolve_athlete_dir(), + filename, + lane=lane, + summary=summary, + key_points=key_points, + evidence_ids=evidence_ids, + known_evidence_ids={entry.id for entry in load_corpus()}, + ) + return DocumentMarked( + filename=record.filename, + lane=record.lane, + summary=record.summary, + evidence_ids=list(record.evidence_ids), + ) + + +def register(mcp: FastMCP) -> None: + """Register the document tools on the server.""" + for tool in (list_athlete_documents, mark_document_processed): + mcp.tool()(tool) diff --git a/tests/memory/test_documents.py b/tests/memory/test_documents.py index 604df17..5d702cd 100644 --- a/tests/memory/test_documents.py +++ b/tests/memory/test_documents.py @@ -4,6 +4,7 @@ import pytest +from performance_agent.memory import store from performance_agent.memory.documents import ( DOCUMENTATION_DIR, README_FILE, @@ -13,6 +14,7 @@ mark_processed, scan_documents, ) +from performance_agent.memory.schemas import Profile TODAY = date(2026, 7, 17) @@ -156,3 +158,8 @@ def test_corrupt_registry_is_rebuilt_empty(tmp_path): (tmp_path / DOCUMENTATION_DIR / REGISTRY_FILE).write_text("not: [valid", encoding="utf-8") result = scan_documents(tmp_path) assert [item["filename"] for item in result["new"]] == ["study.pdf"] + + +def test_write_profile_bootstraps_documentation_folder(tmp_path): + store.write_profile(tmp_path, Profile()) + assert (tmp_path / DOCUMENTATION_DIR / README_FILE).exists() diff --git a/tests/server/test_document_tools.py b/tests/server/test_document_tools.py new file mode 100644 index 0000000..4142864 --- /dev/null +++ b/tests/server/test_document_tools.py @@ -0,0 +1,39 @@ +"""MCP wrappers over the documentation folder.""" + +import pytest + +from performance_agent.server import document_tools + + +@pytest.fixture +def athlete_dir(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + return tmp_path + + +def test_list_creates_folder_and_reports_new(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "study.pdf").write_bytes(b"x") + inventory = document_tools.list_athlete_documents() + assert [item["filename"] for item in inventory["new"]] == ["study.pdf"] + assert (athlete_dir / "documentation" / "README.md").exists() + + +def test_mark_validates_evidence_ids_against_corpus(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "study.pdf").write_bytes(b"x") + with pytest.raises(ValueError, match="not-a-corpus-id"): + document_tools.mark_document_processed( + "study.pdf", lane="evidence", summary="s", evidence_ids=["not-a-corpus-id"] + ) + + +def test_mark_context_then_list_shows_processed(athlete_dir): + (athlete_dir / "documentation").mkdir() + (athlete_dir / "documentation" / "notes.md").write_bytes(b"physio notes") + result = document_tools.mark_document_processed( + "notes.md", lane="context", summary="Physio: avoid loaded flexion 2 weeks." + ) + assert result["lane"] == "context" + inventory = document_tools.list_athlete_documents() + assert inventory["processed"][0]["summary"] == "Physio: avoid loaded flexion 2 weeks." From dcbb608eb8adf78fd45f22422907b35a01a521b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:49:37 +0200 Subject: [PATCH 05/22] Add structured ProgressionRule to exercise blocks Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/schemas.py | 38 ++++++++++++++++ tests/memory/test_schemas_progression.py | 55 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/memory/test_schemas_progression.py diff --git a/src/performance_agent/memory/schemas.py b/src/performance_agent/memory/schemas.py index 867bc6a..6048c7b 100644 --- a/src/performance_agent/memory/schemas.py +++ b/src/performance_agent/memory/schemas.py @@ -344,6 +344,43 @@ class SessionAdjustmentEntry(BaseModel): _VOLUME_FIELDS = ("reps", "duration_min", "distance_m") +class ProgressionRule(BaseModel): + """Machine-readable weekly progression; the engine computes next week from it. + + The free-text progression_rule on the block stays the human rendering; when + this structured rule is present it is the source of computation. Defaults + (3%/RIR, 2.5 kg rounding, the ±10% weekly clamp in the engine) are + team-chosen priors. + """ + + model_config = ConfigDict(extra="forbid") + + kind: Literal["double", "linear_load", "rir_target", "from_pct", "none"] + rep_min: int | None = Field(default=None, ge=1, le=100) + rep_max: int | None = Field(default=None, ge=1, le=100) + increment_kg: float | None = Field(default=None, gt=0, le=50) + target_rir: float | None = Field(default=None, ge=0, le=10) + adjust_pct_per_rir: float = Field(default=0.03, gt=0, le=0.2) + rounding_kg: float = Field(default=2.5, gt=0, le=10) + + @model_validator(mode="after") + def _params_match_kind(self) -> Self: + if self.kind == "double": + if self.rep_min is None or self.rep_max is None or self.increment_kg is None: + msg = "kind=double requires rep_min, rep_max and increment_kg" + raise ValueError(msg) + if self.rep_min >= self.rep_max: + msg = f"rep_min must be < rep_max, got {self.rep_min}..{self.rep_max}" + raise ValueError(msg) + if self.kind == "linear_load" and self.increment_kg is None: + msg = "kind=linear_load requires increment_kg" + raise ValueError(msg) + if self.kind == "rir_target" and self.target_rir is None: + msg = "kind=rir_target requires target_rir" + raise ValueError(msg) + return self + + class ExerciseBlock(BaseModel): """One prescribed exercise inside a session, with a single intensity mode.""" @@ -364,6 +401,7 @@ class ExerciseBlock(BaseModel): pace_s_per_km: float | None = Field(default=None, gt=0, le=3600) rest_s: int | None = Field(default=None, ge=0, le=1800) progression_rule: str = Field(min_length=1) + progression: ProgressionRule | None = None cite: str | None = None notes: str | None = None diff --git a/tests/memory/test_schemas_progression.py b/tests/memory/test_schemas_progression.py new file mode 100644 index 0000000..9bb025c --- /dev/null +++ b/tests/memory/test_schemas_progression.py @@ -0,0 +1,55 @@ +"""ProgressionRule: per-kind parameter validation and block attachment.""" + +import pytest +from pydantic import ValidationError + +from performance_agent.memory.schemas import ExerciseBlock, ProgressionRule + + +def test_double_requires_range_and_increment(): + rule = ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5) + assert rule.rounding_kg == 2.5 + with pytest.raises(ValidationError, match="rep_min"): + ProgressionRule(kind="double", rep_max=12, increment_kg=2.5) + with pytest.raises(ValidationError, match="rep_min"): + ProgressionRule(kind="double", rep_min=12, rep_max=8, increment_kg=2.5) + + +def test_linear_requires_increment(): + ProgressionRule(kind="linear_load", increment_kg=2.5) + with pytest.raises(ValidationError, match="increment_kg"): + ProgressionRule(kind="linear_load") + + +def test_rir_target_requires_target(): + rule = ProgressionRule(kind="rir_target", target_rir=2) + assert rule.adjust_pct_per_rir == 0.03 + with pytest.raises(ValidationError, match="target_rir"): + ProgressionRule(kind="rir_target") + + +def test_from_pct_and_none_take_no_required_params(): + ProgressionRule(kind="from_pct") + ProgressionRule(kind="none") + + +def test_block_accepts_structured_progression_and_stays_optional(): + block = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=4, + reps="8-12", + load_kg=80, + progression_rule="Double progression 8-12, +2.5 kg at the top.", + progression=ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + ) + assert block.progression is not None + legacy = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=4, + reps="8-12", + load_kg=80, + progression_rule="text only", + ) + assert legacy.progression is None From a85e0c4ee6d8c02a6a269638f952850d240a2ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:51:40 +0200 Subject: [PATCH 06/22] Add pure next-load progression engine Co-Authored-By: Claude Fable 5 --- src/performance_agent/engine/progression.py | 130 ++++++++++++++++++++ tests/engine/test_progression.py | 108 ++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/performance_agent/engine/progression.py create mode 100644 tests/engine/test_progression.py diff --git a/src/performance_agent/engine/progression.py b/src/performance_agent/engine/progression.py new file mode 100644 index 0000000..60d235c --- /dev/null +++ b/src/performance_agent/engine/progression.py @@ -0,0 +1,130 @@ +"""Pure weekly progression math: one function per ProgressionRule kind. + +No I/O, no dates. The memory layer (memory/weekly_review.py) matches logs to +blocks and dispatches here. The ±10% weekly cap on autoregulated (RIR) load +moves and the round-half-up-to-increment rounding are team-chosen priors. +""" + +from dataclasses import dataclass +from typing import Protocol + +# Autoregulated weekly load moves are capped at ±10% (team-chosen prior): +# a single week's mean RIR should nudge the load, not rewrite it. +_MAX_RIR_ADJUST_PCT = 0.10 + + +class ProgressionRuleLike(Protocol): + """Structural view of memory.schemas.ProgressionRule (engine stays import-pure).""" + + @property + def kind(self) -> str: + """Rule kind; read-only so the model's Literal type stays assignable.""" + ... + + rep_min: int | None + rep_max: int | None + increment_kg: float | None + target_rir: float | None + adjust_pct_per_rir: float + rounding_kg: float + + +@dataclass(frozen=True) +class SetActual: + """One logged set, already matched to the block being progressed.""" + + reps: int + load_kg: float + rir: float | None = None + + +@dataclass(frozen=True) +class LoadSuggestion: + """The engine's verdict for one block's next week.""" + + next_load_kg: float | None + action: str # increment | hold | decrement | per_plan + flags: tuple[str, ...] = () + + +def round_to_increment(value: float, step: float) -> float: + """Round to the nearest plate step (2.5 kg default upstream).""" + if step <= 0: + msg = f"step must be positive, got {step!r}" + raise ValueError(msg) + return round(value / step) * step + + +def _require(rule: ProgressionRuleLike, field: str) -> float: + value = getattr(rule, field) + if value is None: + msg = f"kind={rule.kind} requires {field}; the schema validator should have caught this" + raise ValueError(msg) + return value + + +def next_load_double( + rule: ProgressionRuleLike, current_load_kg: float, sets: list[SetActual] +) -> LoadSuggestion: + """Double progression: all sets at rep_max -> add increment, else hold.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + rep_min = _require(rule, "rep_min") + rep_max = _require(rule, "rep_max") + increment_kg = _require(rule, "increment_kg") + if all(s.reps >= rep_max for s in sets): + raised = round_to_increment(current_load_kg + increment_kg, rule.rounding_kg) + return LoadSuggestion(raised, "increment") + flags = ("failed_sets",) if any(s.reps < rep_min for s in sets) else () + return LoadSuggestion(current_load_kg, "hold", flags) + + +def next_load_linear( + rule: ProgressionRuleLike, + current_load_kg: float, + prescribed_reps: int, + sets: list[SetActual], +) -> LoadSuggestion: + """Linear load: every set hit the prescribed reps -> add increment, else hold.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + increment_kg = _require(rule, "increment_kg") + if all(s.reps >= prescribed_reps for s in sets): + raised = round_to_increment(current_load_kg + increment_kg, rule.rounding_kg) + return LoadSuggestion(raised, "increment") + return LoadSuggestion(current_load_kg, "hold", ("failed_sets",)) + + +def next_load_rir( + rule: ProgressionRuleLike, current_load_kg: float, sets: list[SetActual] +) -> LoadSuggestion: + """RIR-target autoregulation: adjust_pct_per_rir per point of mean deviation.""" + if not sets: + return LoadSuggestion(None, "hold", ("no_logged_sets",)) + target_rir = _require(rule, "target_rir") + rirs = [s.rir for s in sets if s.rir is not None] + if not rirs: + return LoadSuggestion(current_load_kg, "hold", ("no_rir_logged",)) + delta = sum(rirs) / len(rirs) - target_rir + adjust = rule.adjust_pct_per_rir * delta + flags: tuple[str, ...] = () + if abs(adjust) > _MAX_RIR_ADJUST_PCT: + adjust = _MAX_RIR_ADJUST_PCT if adjust > 0 else -_MAX_RIR_ADJUST_PCT + flags = ("clamped",) + raised = round_to_increment(current_load_kg * (1 + adjust), rule.rounding_kg) + if raised > current_load_kg: + action = "increment" + elif raised < current_load_kg: + action = "decrement" + else: + action = "hold" + return LoadSuggestion(raised, action, flags) + + +def next_load_from_pct( + next_pct_1rm: float, e1rm_kg: float | None, rounding_kg: float +) -> LoadSuggestion: + """Percent-planned blocks: next week's planned pct resolved against e1RM.""" + if e1rm_kg is None: + return LoadSuggestion(None, "per_plan", ("no_e1rm",)) + return LoadSuggestion(round_to_increment(next_pct_1rm * e1rm_kg, rounding_kg), "per_plan") diff --git a/tests/engine/test_progression.py b/tests/engine/test_progression.py new file mode 100644 index 0000000..e984ece --- /dev/null +++ b/tests/engine/test_progression.py @@ -0,0 +1,108 @@ +"""Pure next-load math for each ProgressionRule kind.""" + +import pytest + +from performance_agent.engine.progression import ( + SetActual, + next_load_double, + next_load_from_pct, + next_load_linear, + next_load_rir, + round_to_increment, +) +from performance_agent.memory.schemas import ProgressionRule + +DOUBLE = ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5) +LINEAR = ProgressionRule(kind="linear_load", increment_kg=2.5) +RIR = ProgressionRule(kind="rir_target", target_rir=2) + + +def sets(*reps, load=80.0, rir=None): + return [SetActual(reps=r, load_kg=load, rir=rir) for r in reps] + + +def test_rounding(): + assert round_to_increment(81.4, 2.5) == 82.5 + assert round_to_increment(81.1, 2.5) == 80.0 + + +def test_rounding_rejects_bad_step(): + with pytest.raises(ValueError, match="step"): + round_to_increment(80.0, 0) + + +def test_double_top_of_range_increments(): + result = next_load_double(DOUBLE, 80.0, sets(12, 12, 12, 12)) + assert result.next_load_kg == 82.5 + assert result.action == "increment" + assert result.flags == () + + +def test_double_mid_range_holds(): + result = next_load_double(DOUBLE, 80.0, sets(12, 12, 12, 11)) + assert result.next_load_kg == 80.0 + assert result.action == "hold" + + +def test_double_below_rep_min_holds_with_failed_flag(): + result = next_load_double(DOUBLE, 80.0, sets(8, 7, 6)) + assert result.next_load_kg == 80.0 + assert result.action == "hold" + assert "failed_sets" in result.flags + + +def test_double_no_sets_flags_unmatched(): + result = next_load_double(DOUBLE, 80.0, []) + assert result.next_load_kg is None + assert "no_logged_sets" in result.flags + + +def test_linear_all_sets_at_prescribed_reps_increment(): + result = next_load_linear(LINEAR, 100.0, 5, sets(5, 5, 5, load=100.0)) + assert result.next_load_kg == 102.5 + assert result.action == "increment" + + +def test_linear_missed_reps_holds_with_flag(): + result = next_load_linear(LINEAR, 100.0, 5, sets(5, 4, 3, load=100.0)) + assert result.next_load_kg == 100.0 + assert "failed_sets" in result.flags + + +def test_rir_above_target_raises_load(): + # mean RIR 4 vs target 2 -> +6% on 100 kg -> 106 -> rounds to 105 + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0, rir=4)) + assert result.next_load_kg == 105.0 + assert result.action == "increment" + + +def test_rir_below_target_lowers_load(): + # mean RIR 0 vs target 2 -> -6% -> 94 -> rounds to 95 + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0, rir=0)) + assert result.next_load_kg == 95.0 + assert result.action == "decrement" + + +def test_rir_clamped_to_ten_percent(): + # mean RIR 8 vs target 2 -> raw +18% -> clamped to +10% -> 110 + result = next_load_rir(RIR, 100.0, sets(5, load=100.0, rir=8)) + assert result.next_load_kg == 110.0 + assert "clamped" in result.flags + + +def test_rir_without_logged_rir_holds(): + result = next_load_rir(RIR, 100.0, sets(5, 5, load=100.0)) + assert result.next_load_kg == 100.0 + assert "no_rir_logged" in result.flags + + +def test_from_pct_resolves_next_week_pct(): + result = next_load_from_pct(0.85, 140.0, 2.5) + assert result.next_load_kg == 120.0 + assert result.action == "per_plan" + + +def test_from_pct_without_e1rm_flags(): + result = next_load_from_pct(0.85, None, 2.5) + assert result.next_load_kg is None + assert "no_e1rm" in result.flags From 40241137aa600fa1486fc1ea11d94e14d2a13524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:53:57 +0200 Subject: [PATCH 07/22] Add weekly loads review with week matching and rule dispatch Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/weekly_review.py | 253 ++++++++++++++++++ tests/memory/test_weekly_review.py | 163 +++++++++++ 2 files changed, 416 insertions(+) create mode 100644 src/performance_agent/memory/weekly_review.py create mode 100644 tests/memory/test_weekly_review.py diff --git a/src/performance_agent/memory/weekly_review.py b/src/performance_agent/memory/weekly_review.py new file mode 100644 index 0000000..334f7ae --- /dev/null +++ b/src/performance_agent/memory/weekly_review.py @@ -0,0 +1,253 @@ +"""Weekly loads review: match the logged week to the program, apply each rule. + +Deterministic given `today`. Matching: sessions logged in the last `days_back` +days are matched to program sessions by session_plan_id when present, else by +exercise-name overlap; the program week with the most matched sessions is the +current week (tie -> highest week_index). Suggestions target each block's next +occurrence: same-load rules (double/linear/rir_target) progress the current +block; from_pct resolves the SAME exercise's planned pct in the following week +(fallback: the current block's pct). e1RM comes from the best logged set of +that exercise in the last 14 days (Epley), falling back to the profile's +lift_inventory. A successful run records its date so diligence can see it. +""" + +from datetime import date +from pathlib import Path +from typing import TypedDict + +import yaml + +from performance_agent.engine.progression import ( + SetActual, + next_load_double, + next_load_from_pct, + next_load_linear, + next_load_rir, +) +from performance_agent.engine.strength import MAX_ESTIMATION_REPS, one_rm_epley +from performance_agent.memory import store +from performance_agent.memory.schemas import ( + ExerciseBlock, + ProgramPlan, + SessionEntry, + SessionPlan, + WeekPlan, +) +from performance_agent.programs.render import intensity_label, volume_label + +LOADS_REVIEW_STATE_FILE = "loads-review.yaml" +_DEFAULT_WINDOW_DAYS = 7 +_E1RM_WINDOW_DAYS = 14 + + +class ActualSetView(TypedDict): + """One logged set as facts.""" + + reps: int + load_kg: float + rir: float | None + + +class BlockSuggestionView(TypedDict): + """Next-week verdict for one block (facts; the LLM renders the sentence).""" + + session_id: str + exercise: str + rule_kind: str | None + prescribed_volume: str + prescribed_intensity: str + actual_sets: list[ActualSetView] + next_load_kg: float | None + rationale_key: str + flags: list[str] + + +class WeeklyLoadsView(TypedDict): + """The whole review: matched week, one verdict per block, run-level flags.""" + + week_matched: int | None + blocks: list[BlockSuggestionView] + flags: list[str] + + +def read_last_run(base_dir: Path) -> date | None: + """Date of the last successful review, or None when never run.""" + path = base_dir / LOADS_REVIEW_STATE_FILE + if not path.exists(): + return None + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or "last_run" not in raw: + return None + return date.fromisoformat(str(raw["last_run"])) + + +def _record_run(base_dir: Path, current: date) -> None: + path = base_dir / LOADS_REVIEW_STATE_FILE + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(yaml.safe_dump({"last_run": current.isoformat()}), encoding="utf-8") + tmp.replace(path) + + +def _weeks_in_order(plan: ProgramPlan) -> list[WeekPlan]: + return [week for meso in plan.mesocycles for week in meso.weeks] + + +def _window_sessions(base_dir: Path, current: date, days_back: int) -> list[SessionEntry]: + return [ + entry + for entry in store.read_sessions(base_dir) + if entry.source == "programmed" + and 0 <= (current - entry.performed_at.date()).days < days_back + ] + + +def _logged_names(entries: list[SessionEntry]) -> set[str]: + return {ex.name.casefold() for entry in entries for ex in entry.exercises} + + +def _match_week(weeks: list[WeekPlan], logged: list[SessionEntry]) -> WeekPlan | None: + plan_ids = {entry.session_plan_id for entry in logged if entry.session_plan_id} + names = _logged_names(logged) + + def score(week: WeekPlan) -> int: + if plan_ids: + return sum(1 for session in week.sessions if session.id in plan_ids) + return sum( + 1 + for session in week.sessions + for block in session.blocks + if block.exercise.casefold() in names + ) + + best = max(weeks, key=lambda week: (score(week), week.week_index), default=None) + if best is None or score(best) == 0: + return None + return best + + +def _sets_for_block(block: ExerciseBlock, logged: list[SessionEntry]) -> list[SetActual]: + wanted = block.exercise.casefold() + return [ + SetActual(reps=s.reps, load_kg=s.load_kg, rir=s.rir) + for entry in logged + for ex in entry.exercises + if ex.name.casefold() == wanted + for s in ex.sets + ] + + +def _e1rm_for(base_dir: Path, exercise: str, current: date) -> float | None: + wanted = exercise.casefold() + estimates = [ + one_rm_epley(s.load_kg, s.reps) + for entry in store.read_sessions(base_dir) + if 0 <= (current - entry.performed_at.date()).days < _E1RM_WINDOW_DAYS + for ex in entry.exercises + if ex.name.casefold() == wanted + for s in ex.sets + if s.load_kg > 0 and 1 <= s.reps <= MAX_ESTIMATION_REPS + ] + if estimates: + return max(estimates) + profile = store.read_profile(base_dir) + for record in profile.lift_inventory: + if record.lift.casefold() == wanted: + return record.one_rm_kg + return None + + +def _next_pct_for(block: ExerciseBlock, next_week: WeekPlan | None) -> float | None: + if next_week is not None: + for session in next_week.sessions: + for candidate in session.blocks: + if ( + candidate.exercise.casefold() == block.exercise.casefold() + and candidate.pct_1rm is not None + ): + return candidate.pct_1rm + return block.pct_1rm + + +def _suggest_block( # noqa: PLR0913 -- the six inputs are the review's full context, all distinct + base_dir: Path, + session: SessionPlan, + block: ExerciseBlock, + logged: list[SessionEntry], + next_week: WeekPlan | None, + current: date, +) -> BlockSuggestionView: + view = BlockSuggestionView( + session_id=session.id, + exercise=block.exercise, + rule_kind=block.progression.kind if block.progression else None, + prescribed_volume=volume_label(block), + prescribed_intensity=intensity_label(block), + actual_sets=[], + next_load_kg=None, + rationale_key="no_rule", + flags=[], + ) + sets = _sets_for_block(block, logged) + view["actual_sets"] = [ActualSetView(reps=s.reps, load_kg=s.load_kg, rir=s.rir) for s in sets] + rule = block.progression + if rule is None: + return view + if rule.kind == "none": + view["rationale_key"] = "per_plan" + return view + if rule.kind == "from_pct": + pct = _next_pct_for(block, next_week) + if pct is None: + view["rationale_key"] = "per_plan" + view["flags"] = ["no_pct_prescribed"] + return view + result = next_load_from_pct( + pct, _e1rm_for(base_dir, block.exercise, current), rule.rounding_kg + ) + elif rule.kind == "double": + result = next_load_double(rule, block.load_kg or 0.0, sets) + elif rule.kind == "linear_load": + reps = block.reps or "" + if not reps.isdigit(): + view["rationale_key"] = "hold" + view["flags"] = ["ambiguous_reps"] + return view + result = next_load_linear(rule, block.load_kg or 0.0, int(reps), sets) + else: # rir_target + result = next_load_rir(rule, block.load_kg or 0.0, sets) + view["next_load_kg"] = result.next_load_kg + view["rationale_key"] = result.action + view["flags"] = list(result.flags) + return view + + +def suggest_next_week_loads( + base_dir: Path, today: date | None = None, days_back: int = _DEFAULT_WINDOW_DAYS +) -> WeeklyLoadsView: + """Compute every block's next-week load from the logged week (see module doc).""" + if days_back < 1: + msg = f"days_back must be >= 1, got {days_back!r}" + raise ValueError(msg) + current = today or date.today() + program = store.read_program(base_dir) + if program is None: + msg = "no program has been saved yet; save a program before a loads review" + raise ValueError(msg) + if program.plan is None: + msg = "the active program is legacy prose-only; a structured plan is required" + raise ValueError(msg) + weeks = _weeks_in_order(program.plan) + logged = _window_sessions(base_dir, current, days_back) + week = _match_week(weeks, logged) + if week is None: + return WeeklyLoadsView(week_matched=None, blocks=[], flags=["no_matched_week"]) + position = weeks.index(week) + next_week = weeks[position + 1] if position + 1 < len(weeks) else None + flags = [] if next_week is not None else ["last_week"] + blocks = [ + _suggest_block(base_dir, session, block, logged, next_week, current) + for session in week.sessions + for block in session.blocks + ] + _record_run(base_dir, current) + return WeeklyLoadsView(week_matched=week.week_index, blocks=blocks, flags=flags) diff --git a/tests/memory/test_weekly_review.py b/tests/memory/test_weekly_review.py new file mode 100644 index 0000000..094de15 --- /dev/null +++ b/tests/memory/test_weekly_review.py @@ -0,0 +1,163 @@ +"""Weekly loads review: week matching, rule dispatch, state file.""" + +from datetime import date, datetime +from typing import Any + +import pytest + +from performance_agent.memory import store, weekly_review +from performance_agent.memory.schemas import ( + ExerciseBlock, + ExercisePerformed, + Fallbacks, + Mesocycle, + Profile, + ProgramPlan, + ProgressionRule, + SessionEntry, + SessionPlan, + SetPerformed, + WeekPlan, +) + +TODAY = date(2026, 7, 17) +FALLBACKS = Fallbacks( + low_readiness="halve", short_on_time="cut accessories", missing_equipment="dumbbells" +) + + +def _block(exercise="Bench press", **overrides): + fields: dict[str, Any] = { + "exercise": exercise, + "priority": "primary", + "sets": 3, + "reps": "8-12", + "load_kg": 80.0, + "rest_s": 120, + "progression_rule": "Double progression 8-12, +2.5 kg at the top.", + "progression": ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + } + fields.update(overrides) + return ExerciseBlock(**fields) + + +def _week(index, blocks): + return WeekPlan( + week_index=index, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id=f"w{index}-a", + weekday=0, + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper strength", + blocks=blocks, + fallbacks=FALLBACKS, + ) + ], + ) + + +def _save_program(base, weeks): + plan = ProgramPlan( + version=1, + goal_id="bench-goal", + created_on=TODAY, + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=weeks)], + ) + store.save_program(base, plan, today=TODAY) + + +def _log( # noqa: PLR0913 -- test helper mirroring the full SessionEntry shape + base, day, session_plan_id, exercise="Bench press", reps=(12, 12, 12), load=80.0, rir=None +): + entry = SessionEntry( + performed_at=datetime(day.year, day.month, day.day, 18, 0), + session_plan_id=session_plan_id, + exercises=[ + ExercisePerformed( + name=exercise, + sets=[SetPerformed(reps=r, load_kg=load, rir=rir) for r in reps], + ) + ], + ) + store.append_session(base, entry) + + +def test_no_program_raises(tmp_path): + with pytest.raises(ValueError, match="no program"): + weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + + +def test_double_progression_increment_end_to_end(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert view["week_matched"] == 1 + [block] = view["blocks"] + assert block["exercise"] == "Bench press" + assert block["next_load_kg"] == 82.5 + assert block["rationale_key"] == "increment" + + +def test_unmatched_block_is_flagged_not_guessed(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program( + tmp_path, + [_week(1, [_block(), _block(exercise="Squat")]), _week(2, [_block()])], + ) + _log(tmp_path, date(2026, 7, 13), "w1-a") # only bench logged + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + squat = next(b for b in view["blocks"] if b["exercise"] == "Squat") + assert squat["next_load_kg"] is None + assert "no_logged_sets" in squat["flags"] + + +def test_block_without_structured_rule_flags_no_rule(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block(progression=None)]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + [block] = view["blocks"] + assert block["rationale_key"] == "no_rule" + assert block["next_load_kg"] is None + + +def test_from_pct_uses_next_week_pct_and_logged_e1rm(tmp_path): + store.write_profile(tmp_path, Profile()) + week1 = _week( + 1, + [_block(load_kg=None, pct_1rm=0.8, progression=ProgressionRule(kind="from_pct"))], + ) + week2 = _week( + 2, + [_block(load_kg=None, pct_1rm=0.85, progression=ProgressionRule(kind="from_pct"))], + ) + _save_program(tmp_path, [week1, week2]) + # best set 100x5 -> Epley e1RM 116.7 -> 0.85 * 116.7 = 99.2 -> rounds to 100 + _log(tmp_path, date(2026, 7, 13), "w1-a", reps=(5, 5), load=100.0) + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + [block] = view["blocks"] + assert block["next_load_kg"] == 100.0 + assert block["rationale_key"] == "per_plan" + + +def test_state_file_records_the_run(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + _log(tmp_path, date(2026, 7, 13), "w1-a") + assert weekly_review.read_last_run(tmp_path) is None + weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert weekly_review.read_last_run(tmp_path) == TODAY + + +def test_no_matching_week_returns_empty_with_flag(tmp_path): + store.write_profile(tmp_path, Profile()) + _save_program(tmp_path, [_week(1, [_block()]), _week(2, [_block()])]) + view = weekly_review.suggest_next_week_loads(tmp_path, today=TODAY) + assert view["week_matched"] is None + assert view["blocks"] == [] + assert "no_matched_week" in view["flags"] From b483579a1e4986670a512d37b88d53418828306c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:54:52 +0200 Subject: [PATCH 08/22] Add watch report versioned doc family Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/store.py | 44 +++++++++++++++++++++++++++ tests/memory/test_store_watch.py | 28 +++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/memory/test_store_watch.py diff --git a/src/performance_agent/memory/store.py b/src/performance_agent/memory/store.py index 63e5d5f..f69c435 100644 --- a/src/performance_agent/memory/store.py +++ b/src/performance_agent/memory/store.py @@ -54,6 +54,7 @@ MACRO_PLAN_PREFIX = "macro-plan" EXERCISES_DIR = "exercises" EXERCISE_LIBRARY_FILE = "library.yaml" +WATCH_DIR = "watch" _FRONTMATTER_DELIMITER = "---\n" _FRONTMATTER_DELIMITER_COUNT = 2 @@ -805,3 +806,46 @@ def read_nutrition_frame( label="nutrition frame", version=version, ) + + +def latest_watch_report_version(base_dir: Path) -> int | None: + """Return the highest existing watch-report version, or None.""" + return _latest_doc_version(base_dir, WATCH_DIR, "report") + + +def save_watch_report( + base_dir: Path, + markdown_body: str, + goal_id: str, + reason: str | None = None, + today: date | None = None, +) -> tuple[Path, int]: + """Write the next program-watch report version; v2+ requires a reason. + + Same immutable-version audit trail as the other doc families; lives in + watch/. The latest report's created_on is also the diligence anchor for + "program watch due". + """ + return _save_versioned_doc( + base_dir, + markdown_body, + goal_id, + subdir=WATCH_DIR, + prefix="report", + label="watch report", + reason=reason, + today=today, + ) + + +def read_watch_report( + base_dir: Path, version: int | None = None +) -> tuple[dict[str, object], str] | None: + """Return (frontmatter, body) for the given or latest watch report; None when empty.""" + return _read_versioned_doc( + base_dir, + subdir=WATCH_DIR, + prefix="report", + label="watch report", + version=version, + ) diff --git a/tests/memory/test_store_watch.py b/tests/memory/test_store_watch.py new file mode 100644 index 0000000..f80cb08 --- /dev/null +++ b/tests/memory/test_store_watch.py @@ -0,0 +1,28 @@ +"""Watch reports: immutable versioned docs under watch/.""" + +import pytest + +from performance_agent.memory import store + + +def test_first_report_is_v1_and_readable(tmp_path): + path, version = store.save_watch_report(tmp_path, "All lifts on track.", "goal-1") + assert version == 1 + assert path == tmp_path / "watch" / "report-v1.md" + stored = store.read_watch_report(tmp_path) + assert stored is not None + frontmatter, body = stored + assert frontmatter["version"] == 1 + assert body == "All lifts on track." + + +def test_v2_requires_reason(tmp_path): + store.save_watch_report(tmp_path, "v1", "goal-1") + with pytest.raises(ValueError, match="reason"): + store.save_watch_report(tmp_path, "v2", "goal-1") + _, version = store.save_watch_report(tmp_path, "v2", "goal-1", reason="biweekly watch") + assert version == 2 + + +def test_latest_version_none_when_empty(tmp_path): + assert store.latest_watch_report_version(tmp_path) is None From e62a4f893b36d3603099d25a58c2ed92124cf0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:55:47 +0200 Subject: [PATCH 09/22] Add suggest_next_week_loads and save_watch_report tools Co-Authored-By: Claude Fable 5 --- src/performance_agent/server/app.py | 2 + .../server/followup_tools.py | 45 +++++++++ tests/server/test_followup_tools.py | 97 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/performance_agent/server/followup_tools.py create mode 100644 tests/server/test_followup_tools.py diff --git a/src/performance_agent/server/app.py b/src/performance_agent/server/app.py index 2f530b7..d8a8f23 100644 --- a/src/performance_agent/server/app.py +++ b/src/performance_agent/server/app.py @@ -9,6 +9,7 @@ engine_tools, evidence_tools, exercise_tools, + followup_tools, import_tools, macro_tools, memory_tools, @@ -31,6 +32,7 @@ taper_tools.register(mcp) macro_tools.register(mcp) document_tools.register(mcp) +followup_tools.register(mcp) def main() -> None: diff --git a/src/performance_agent/server/followup_tools.py b/src/performance_agent/server/followup_tools.py new file mode 100644 index 0000000..29440b7 --- /dev/null +++ b/src/performance_agent/server/followup_tools.py @@ -0,0 +1,45 @@ +"""MCP tools for the weekly follow-up: loads review and watch reports.""" + +from mcp.server.fastmcp import FastMCP + +from performance_agent.memory import store, weekly_review +from performance_agent.memory.paths import resolve_athlete_dir +from performance_agent.memory.weekly_review import WeeklyLoadsView +from performance_agent.server.memory_tools import VersionedDocSaved + + +def suggest_next_week_loads(days_back: int = 7) -> WeeklyLoadsView: + """Compute next week's load for every block of the logged program week. + + Deterministic engine math, zero guessing: logged sessions from the last + days_back days are matched to the program (session_plan_id first, exercise + names as fallback); each block's structured progression rule then yields + {next_load_kg, rationale_key, flags}. Degraded cases are flags, never + guesses: no_rule (unstructured block — handle it conversationally), + no_logged_sets, failed_sets (hold), no_rir_logged, no_e1rm, clamped, + ambiguous_reps; week-level: no_matched_week, last_week. Quote the numbers + and the rationale to the athlete; this NEVER modifies the program. A + successful run is recorded so list_due_actions can see the review happened. + """ + return weekly_review.suggest_next_week_loads(resolve_athlete_dir(), days_back=days_back) + + +def save_watch_report( + markdown_body: str, goal_id: str, reason: str | None = None +) -> VersionedDocSaved: + """Write the NEXT program-watch report version (immutable audit trail). + + The report is the program-watch skill's output: per-exercise verdicts + (keep / watch / substitution candidate) with the data behind each one. + Version 1 needs no reason; every later report (v2+) requires a reason + naming its trigger (biweekly watch, mesocycle boundary, athlete request). + Saving also timestamps the watch for list_due_actions. + """ + path, version = store.save_watch_report(resolve_athlete_dir(), markdown_body, goal_id, reason) + return VersionedDocSaved(path=str(path), version=version) + + +def register(mcp: FastMCP) -> None: + """Register the follow-up tools on the server.""" + for tool in (suggest_next_week_loads, save_watch_report): + mcp.tool()(tool) diff --git a/tests/server/test_followup_tools.py b/tests/server/test_followup_tools.py new file mode 100644 index 0000000..10f3f4e --- /dev/null +++ b/tests/server/test_followup_tools.py @@ -0,0 +1,97 @@ +"""MCP wrappers for the weekly follow-up.""" + +from datetime import date, datetime + +import pytest + +from performance_agent.memory import store +from performance_agent.memory.schemas import ( + ExerciseBlock, + ExercisePerformed, + Fallbacks, + Mesocycle, + Profile, + ProgramPlan, + ProgressionRule, + SessionEntry, + SessionPlan, + SetPerformed, + WeekPlan, +) +from performance_agent.server import followup_tools + + +@pytest.fixture +def athlete_dir(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + store.write_profile(tmp_path, Profile()) + return tmp_path + + +def _seed_program_and_log(base): + block = ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8-12", + load_kg=80.0, + progression_rule="Double progression 8-12, +2.5 kg at the top.", + progression=ProgressionRule(kind="double", rep_min=8, rep_max=12, increment_kg=2.5), + ) + week = WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="w1-a", + weekday=0, + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[block], + fallbacks=Fallbacks( + low_readiness="halve", short_on_time="cut", missing_equipment="dumbbells" + ), + ) + ], + ) + plan = ProgramPlan( + version=1, + goal_id="bench-goal", + created_on=date(2026, 7, 13), + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=[week])], + ) + store.save_program(base, plan, today=date(2026, 7, 13)) + store.append_session( + base, + SessionEntry( + performed_at=datetime(2026, 7, 15, 18, 0), + session_plan_id="w1-a", + exercises=[ + ExercisePerformed( + name="Bench press", + sets=[SetPerformed(reps=12, load_kg=80.0) for _ in range(3)], + ) + ], + ), + ) + + +def test_suggest_returns_block_verdicts(athlete_dir): + _seed_program_and_log(athlete_dir) + view = followup_tools.suggest_next_week_loads() + assert view["week_matched"] == 1 + assert view["blocks"][0]["next_load_kg"] == 82.5 + + +@pytest.mark.usefixtures("athlete_dir") +def test_suggest_rejects_bad_window(): + with pytest.raises(ValueError, match="days_back"): + followup_tools.suggest_next_week_loads(days_back=0) + + +@pytest.mark.usefixtures("athlete_dir") +def test_save_watch_report_versions(): + result = followup_tools.save_watch_report("All on track.", "bench-goal") + assert result["version"] == 1 From 1d4faed0934d57b2a1820633920e75d1aa5ce090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:57:01 +0200 Subject: [PATCH 10/22] Surface loads-review and program-watch due actions Co-Authored-By: Claude Fable 5 --- src/performance_agent/engine/diligence.py | 39 +++++++++++++ src/performance_agent/memory/diligence.py | 31 +++++++++- src/performance_agent/server/memory_tools.py | 6 +- tests/engine/test_diligence.py | 60 ++++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/src/performance_agent/engine/diligence.py b/src/performance_agent/engine/diligence.py index 75c6b90..950b671 100644 --- a/src/performance_agent/engine/diligence.py +++ b/src/performance_agent/engine/diligence.py @@ -32,6 +32,10 @@ # Two reds in a row is a medium concern; three-plus is high (persistent under-recovery). _RED_STREAK_MEDIUM = 2 _RED_STREAK_HIGH = 3 +# A training week with logged sessions deserves a loads review within six days; +# a running program deserves a watch pass every two weeks (team-chosen priors). +_LOADS_REVIEW_DUE_DAYS = 6 +_WATCH_DUE_DAYS = 14 _SEVERITY_RANK: dict[Severity, int] = {"high": 0, "medium": 1, "low": 2} @@ -52,6 +56,9 @@ class DiligenceFacts: has_program gates check-in nagging (no program => no cadence expectation). days_since_checkin is None when nothing has ever been logged. profile_stale_days is None when no response profile exists (staleness only applies once one does). + days_since_loads_review is None when no review was ever recorded; + days_since_watch_anchor counts from the newest of program start / last watch + report, None without a program. """ has_program: bool @@ -64,6 +71,9 @@ class DiligenceFacts: goal_deadline_without_events: bool = False profile_stale_days: int | None = None readiness_red_streak: int = 0 + sessions_logged_last_week: int = 0 + days_since_loads_review: int | None = None + days_since_watch_anchor: int | None = None @dataclass(frozen=True) @@ -156,6 +166,33 @@ def _red_streak_action(facts: DiligenceFacts) -> DueAction | None: ) +def _loads_review_action(facts: DiligenceFacts) -> DueAction | None: + if not facts.has_program or facts.sessions_logged_last_week < 1: + return None + never = facts.days_since_loads_review is None + if not never and (facts.days_since_loads_review or 0) < _LOADS_REVIEW_DUE_DAYS: + return None + return DueAction( + "loads_review", + "medium", + "loads_review_due", + due_since_days=facts.days_since_loads_review, + ) + + +def _watch_action(facts: DiligenceFacts) -> DueAction | None: + if not facts.has_program or facts.days_since_watch_anchor is None: + return None + if facts.days_since_watch_anchor < _WATCH_DUE_DAYS: + return None + return DueAction( + "program_watch", + "medium", + "program_watch_due", + due_since_days=facts.days_since_watch_anchor, + ) + + def _sort_key(action: DueAction) -> tuple[int, int, str, str]: # Severity first; then most urgent (soonest upcoming, longest overdue); then a # stable tiebreak on kind + ref so the order is fully deterministic. @@ -182,6 +219,8 @@ def list_due_actions(facts: DiligenceFacts) -> list[DueAction]: _calendar_action(facts), _profile_action(facts), _red_streak_action(facts), + _loads_review_action(facts), + _watch_action(facts), ] candidates.extend(_event_action(event) for event in facts.upcoming_events) actions = [action for action in candidates if action is not None] diff --git a/src/performance_agent/memory/diligence.py b/src/performance_agent/memory/diligence.py index 3782789..a9d3d16 100644 --- a/src/performance_agent/memory/diligence.py +++ b/src/performance_agent/memory/diligence.py @@ -20,7 +20,7 @@ ) from performance_agent.engine.diligence import list_due_actions as list_due_actions_engine from performance_agent.engine.load import readiness_score -from performance_agent.memory import store +from performance_agent.memory import store, weekly_review from performance_agent.memory.schemas import ProgramPlan, ReadinessEntry from performance_agent.memory.time_context import TimeContext, build_time_context @@ -125,6 +125,32 @@ def _readiness_red_streak(base_dir: Path, current: date) -> int: return streak +def _sessions_logged_last_week(base_dir: Path, current: date) -> int: + return sum( + 1 + for entry in store.read_sessions(base_dir) + if 0 <= (current - entry.performed_at.date()).days < _MISSED_WINDOW_DAYS + ) + + +def _days_since_loads_review(base_dir: Path, current: date) -> int | None: + last_run = weekly_review.read_last_run(base_dir) + return None if last_run is None else (current - last_run).days + + +def _days_since_watch_anchor(base_dir: Path, current: date) -> int | None: + """Days since the newest of program start / latest watch report, or None.""" + program = store.read_program(base_dir) + if program is None: + return None + anchor = date.fromisoformat(program.created_on) + report = store.read_watch_report(base_dir) + if report is not None: + frontmatter, _ = report + anchor = max(anchor, date.fromisoformat(str(frontmatter["created_on"]))) + return (current - anchor).days + + def _upcoming_events(context: TimeContext) -> tuple[UpcomingEvent, ...]: events = [ UpcomingEvent( @@ -154,6 +180,9 @@ def _build_facts(base_dir: Path, current: date) -> DiligenceFacts: goal_deadline_without_events=_goal_deadline_without_events(base_dir), profile_stale_days=_profile_stale_days(base_dir, current), readiness_red_streak=_readiness_red_streak(base_dir, current), + sessions_logged_last_week=_sessions_logged_last_week(base_dir, current), + days_since_loads_review=_days_since_loads_review(base_dir, current), + days_since_watch_anchor=_days_since_watch_anchor(base_dir, current), ) diff --git a/src/performance_agent/server/memory_tools.py b/src/performance_agent/server/memory_tools.py index e730cd1..af98ea2 100644 --- a/src/performance_agent/server/memory_tools.py +++ b/src/performance_agent/server/memory_tools.py @@ -569,8 +569,10 @@ def list_due_actions() -> list[DueActionView]: profile to surface: an overdue check-in, an A/B event within three weeks (taper/peaking about to start), planned sessions missed this week, three-plus training days with no readiness read, an active goal whose deadline has no dated - events, a response profile older than six weeks, and a streak of red readiness - days. Each action is {kind, severity, due_since_days|due_in_days, message_key, + events, a response profile older than six weeks, a streak of red readiness + days, a finished training week that never got its loads review, and a program + unaudited for two weeks (program watch). + Each action is {kind, severity, due_since_days|due_in_days, message_key, ref}; render the message_key yourself in the athlete's language and quote the numbers. An all-green athlete returns []. Severity is high, medium or low. """ diff --git a/tests/engine/test_diligence.py b/tests/engine/test_diligence.py index cef4681..c7d0e81 100644 --- a/tests/engine/test_diligence.py +++ b/tests/engine/test_diligence.py @@ -132,3 +132,63 @@ def test_ties_break_on_urgency_soonest_event_first(): def test_zero_cadence_is_rejected(): with pytest.raises(ValueError, match="checkin_cadence_days"): list_due_actions(_facts(checkin_cadence_days=0)) + + +def test_loads_review_due_when_sessions_logged_and_never_reviewed(): + facts = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=3, + days_since_loads_review=None, + ) + kinds = {action.kind for action in list_due_actions(facts)} + assert "loads_review" in kinds + + +def test_loads_review_quiet_without_recent_sessions_or_when_fresh(): + quiet = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=0, + days_since_loads_review=None, + ) + fresh = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + sessions_logged_last_week=3, + days_since_loads_review=2, + ) + for facts in (quiet, fresh): + kinds = {action.kind for action in list_due_actions(facts)} + assert "loads_review" not in kinds + + +def test_program_watch_due_after_fourteen_days(): + facts = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + days_since_watch_anchor=15, + ) + kinds = {action.kind for action in list_due_actions(facts)} + assert "program_watch" in kinds + + +def test_program_watch_quiet_when_recent_or_no_program(): + recent = DiligenceFacts( + has_program=True, + checkin_cadence_days=7, + days_since_checkin=1, + days_since_watch_anchor=13, + ) + no_program = DiligenceFacts( + has_program=False, + checkin_cadence_days=7, + days_since_watch_anchor=100, + ) + for facts in (recent, no_program): + kinds = {action.kind for action in list_due_actions(facts)} + assert "program_watch" not in kinds From e8e0210109900f12923b983b04b5c59922b184e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:57:54 +0200 Subject: [PATCH 11/22] Record a loads review in the all-green diligence fixture Co-Authored-By: Claude Fable 5 --- tests/memory/test_diligence.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/memory/test_diligence.py b/tests/memory/test_diligence.py index f487df8..344eeb0 100644 --- a/tests/memory/test_diligence.py +++ b/tests/memory/test_diligence.py @@ -23,6 +23,7 @@ upsert_calendar_event, upsert_goal, ) +from performance_agent.memory.weekly_review import LOADS_REVIEW_STATE_FILE from tests.program_plans import a_session, minimal_plan TODAY = date(2026, 7, 13) @@ -55,8 +56,8 @@ def test_empty_directory_is_all_green(tmp_path): def test_fully_tended_athlete_is_all_green(tmp_path): - # One-weekday plan, a session + green readiness two days ago, a recent check-in, - # no dated deadline, no profile: nothing is due. + # One-weekday plan, a session + green readiness two days ago, a recent check-in + # and loads review, no dated deadline, no profile: nothing is due. save_program(tmp_path, minimal_plan()) two_days_ago = datetime.combine(TODAY - timedelta(days=2), datetime.min.time().replace(hour=18)) append_session(tmp_path, SessionEntry(performed_at=two_days_ago)) @@ -65,6 +66,9 @@ def test_fully_tended_athlete_is_all_green(tmp_path): ReadinessEntry(at=two_days_ago, sleep=1, fatigue=1, soreness=1, stress=1), ) append_checkin(tmp_path, CheckinEntry(at=two_days_ago)) + (tmp_path / LOADS_REVIEW_STATE_FILE).write_text( + f"last_run: {(TODAY - timedelta(days=1)).isoformat()}\n", encoding="utf-8" + ) assert list_due_actions(tmp_path, today=TODAY) == [] From 258c4ba5bac90c1d62cb9de71db7b377833e5ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 21:59:39 +0200 Subject: [PATCH 12/22] Add Guidance advice/rationale to ProgramPlan Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/schemas.py | 15 +++++++ tests/memory/test_schemas_progression.py | 54 +++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/performance_agent/memory/schemas.py b/src/performance_agent/memory/schemas.py index 6048c7b..b0206b3 100644 --- a/src/performance_agent/memory/schemas.py +++ b/src/performance_agent/memory/schemas.py @@ -491,6 +491,19 @@ class TestMilestone(BaseModel): targets: list[str] = Field(min_length=1) +class Guidance(BaseModel): + """One header guidance line: advice or program rationale, optionally cited. + + cite is a corpus id (same semantics as ExerciseBlock.cite). Without one the + line must read as coaching judgment — never a fake citation. + """ + + model_config = ConfigDict(extra="forbid") + + text: str = Field(min_length=1, max_length=300) + cite: str | None = None + + class ProgramPlan(BaseModel): """Structured program: the source of truth the markdown is rendered from.""" @@ -504,6 +517,8 @@ class ProgramPlan(BaseModel): checkin_cadence_days: int = Field(default=7, ge=1, le=90) season_ref: str | None = None test_milestones: list[TestMilestone] = Field(default_factory=list) + advice: list[Guidance] = Field(default_factory=list) + rationale: list[Guidance] = Field(default_factory=list) mesocycles: list[Mesocycle] = Field(min_length=1) @model_validator(mode="after") diff --git a/tests/memory/test_schemas_progression.py b/tests/memory/test_schemas_progression.py index 9bb025c..43f38f8 100644 --- a/tests/memory/test_schemas_progression.py +++ b/tests/memory/test_schemas_progression.py @@ -1,9 +1,20 @@ """ProgressionRule: per-kind parameter validation and block attachment.""" +from datetime import date + import pytest from pydantic import ValidationError -from performance_agent.memory.schemas import ExerciseBlock, ProgressionRule +from performance_agent.memory.schemas import ( + ExerciseBlock, + Fallbacks, + Guidance, + Mesocycle, + ProgramPlan, + ProgressionRule, + SessionPlan, + WeekPlan, +) def test_double_requires_range_and_increment(): @@ -53,3 +64,44 @@ def test_block_accepts_structured_progression_and_stays_optional(): progression_rule="text only", ) assert legacy.progression is None + + +def test_program_plan_carries_optional_guidance(): + week = WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="a", + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[ + ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8", + load_kg=80, + progression_rule="hold", + ) + ], + fallbacks=Fallbacks( + low_readiness="halve", short_on_time="cut", missing_equipment="dumbbells" + ), + ) + ], + ) + plan = ProgramPlan( + version=1, + goal_id="g", + created_on=date(2026, 7, 17), + mesocycles=[Mesocycle(index=1, phase="accumulation", weeks=[week])], + advice=[Guidance(text="Creatine 5 g/day.", cite="creatine-2017")], + rationale=[Guidance(text="12-16 hard sets per muscle per week.")], + ) + assert plan.advice[0].cite == "creatine-2017" + assert plan.rationale[0].cite is None + bare = plan.model_copy(update={"advice": [], "rationale": []}) + assert bare.advice == [] From 0824eb2a8f8d16d3388c1d3b9830a039c9feac0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:00:16 +0200 Subject: [PATCH 13/22] Resolve plan citation ids against the corpus Co-Authored-By: Claude Fable 5 --- src/performance_agent/evidence/citations.py | 39 +++++++++- src/performance_agent/programs/render.py | 24 ++++++ tests/evidence/test_citations_resolution.py | 82 +++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/evidence/test_citations_resolution.py diff --git a/src/performance_agent/evidence/citations.py b/src/performance_agent/evidence/citations.py index 83de64c..525aaca 100644 --- a/src/performance_agent/evidence/citations.py +++ b/src/performance_agent/evidence/citations.py @@ -8,8 +8,11 @@ """ import re +from collections.abc import Iterable +from dataclasses import dataclass -from performance_agent.evidence.schemas import EvidenceEntry +from performance_agent.evidence.corpus import load_corpus +from performance_agent.evidence.schemas import STARS, EvidenceEntry _DOI_PATTERN = re.compile(r"10\.\d{4,9}/[^\s\"'<>)\]]+", re.IGNORECASE) _PMID_PATTERN = re.compile(r"\bPMID:?\s*(\d{6,9})\b", re.IGNORECASE) @@ -74,3 +77,37 @@ def find_unknown_references(text: str, corpus: list[EvidenceEntry]) -> list[str] if _normalized_isbn(isbn) not in known_isbns: unknown.append(f"ISBN:{isbn}") return unknown + + +@dataclass(frozen=True) +class ResolvedCitation: + """A corpus id rendered for a deliverable bibliography.""" + + citation: str + stars: str + doi: str | None + pmid: str | None + + +def resolve_citations(ids: Iterable[str]) -> dict[str, ResolvedCitation]: + """Resolve corpus ids to formatted citations; unknown ids are a hard error. + + This is the render-side anti-fabrication lock for structured plans: a plan + whose advice/rationale/blocks cite an id that is not in the corpus refuses + to save. + """ + entries = {entry.id: entry for entry in load_corpus()} + wanted = list(dict.fromkeys(ids)) + unknown = [cid for cid in wanted if cid not in entries] + if unknown: + msg = f"citation ids not in the evidence corpus: {unknown}" + raise ValueError(msg) + return { + cid: ResolvedCitation( + citation=format_citation(entries[cid]), + stars=STARS[entries[cid].evidence_level], + doi=entries[cid].doi, + pmid=entries[cid].pmid, + ) + for cid in wanted + } diff --git a/src/performance_agent/programs/render.py b/src/performance_agent/programs/render.py index 4676af9..e5ad608 100644 --- a/src/performance_agent/programs/render.py +++ b/src/performance_agent/programs/render.py @@ -175,3 +175,27 @@ def render_program(plan: ProgramPlan) -> str: lines.append("") lines.extend(_week_lines(week)) return "\n".join(lines).strip() + "\n" + + +def plan_citation_ids(plan: ProgramPlan) -> list[str]: + """Every corpus id the plan cites, in order of first appearance, deduplicated. + + Order: advice, then rationale, then blocks in program order — this is the + [n] numbering of the HTML page and the Sources section. + """ + ids: list[str] = [] + seen: set[str] = set() + + def add(cite: str | None) -> None: + if cite and cite not in seen: + seen.add(cite) + ids.append(cite) + + for guidance in (*plan.advice, *plan.rationale): + add(guidance.cite) + for meso in plan.mesocycles: + for week in meso.weeks: + for session in week.sessions: + for block in session.blocks: + add(block.cite) + return ids diff --git a/tests/evidence/test_citations_resolution.py b/tests/evidence/test_citations_resolution.py new file mode 100644 index 0000000..47c94ef --- /dev/null +++ b/tests/evidence/test_citations_resolution.py @@ -0,0 +1,82 @@ +"""Corpus-id resolution for deliverable bibliographies.""" + +from datetime import date + +import pytest + +from performance_agent.evidence.citations import resolve_citations +from performance_agent.evidence.corpus import load_corpus +from performance_agent.memory.schemas import ( + ExerciseBlock, + Fallbacks, + Guidance, + Mesocycle, + ProgramPlan, + SessionPlan, + WeekPlan, +) +from performance_agent.programs.render import plan_citation_ids + + +def test_resolves_known_ids_with_stars(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + entry = load_corpus()[0] + resolved = resolve_citations([entry.id]) + citation = resolved[entry.id] + assert entry.title in citation.citation + assert "★" in citation.stars + + +def test_unknown_id_is_a_hard_error(monkeypatch, tmp_path): + monkeypatch.setenv("PERFORMANCE_AGENT_HOME", str(tmp_path)) + with pytest.raises(ValueError, match="phantom-id"): + resolve_citations(["phantom-id"]) + + +def test_plan_citation_ids_orders_and_dedupes(): + def block(cite): + return ExerciseBlock( + exercise="Bench press", + priority="primary", + sets=3, + reps="8", + load_kg=80, + progression_rule="hold", + cite=cite, + ) + + plan = ProgramPlan( + version=1, + goal_id="g", + created_on=date(2026, 7, 17), + advice=[Guidance(text="Creatine.", cite="id-a")], + rationale=[Guidance(text="Volume.", cite="id-b"), Guidance(text="Judgment.")], + mesocycles=[ + Mesocycle( + index=1, + phase="accumulation", + weeks=[ + WeekPlan( + week_index=1, + volume_factor=1.0, + intensity_factor=1.0, + sessions=[ + SessionPlan( + id="a", + qualities=["strength_heavy"], + est_minutes=60, + purpose="Upper", + blocks=[block("id-c"), block("id-a")], + fallbacks=Fallbacks( + low_readiness="halve", + short_on_time="cut", + missing_equipment="dumbbells", + ), + ) + ], + ) + ], + ) + ], + ) + assert plan_citation_ids(plan) == ["id-a", "id-b", "id-c"] From 695b812b23599ea780ede8b2f081f572b4ccadc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:03:02 +0200 Subject: [PATCH 14/22] Render advice, rationale and starred bibliography in program views Co-Authored-By: Claude Fable 5 --- src/performance_agent/programs/render.py | 40 +++++- src/performance_agent/programs/render_html.py | 118 ++++++++++++++++-- tests/programs/test_render.py | 31 +++++ tests/programs/test_render_html.py | 53 +++++++- 4 files changed, 228 insertions(+), 14 deletions(-) diff --git a/src/performance_agent/programs/render.py b/src/performance_agent/programs/render.py index e5ad608..6dbc838 100644 --- a/src/performance_agent/programs/render.py +++ b/src/performance_agent/programs/render.py @@ -6,7 +6,10 @@ so a printed program carries the ramp-up sets a coach writes by hand. """ +from collections.abc import Mapping + from performance_agent.engine import warmup_scheme +from performance_agent.evidence.citations import ResolvedCitation from performance_agent.memory.schemas import ( ExerciseBlock, ProgramPlan, @@ -166,14 +169,47 @@ def _header_lines(plan: ProgramPlan) -> list[str]: return lines -def render_program(plan: ProgramPlan) -> str: - """Render a ProgramPlan to the human markdown view (deterministic).""" +def _guidance_lines(plan: ProgramPlan) -> list[str]: + lines: list[str] = [] + for title, items in (("Advice", plan.advice), ("Why this program", plan.rationale)): + if not items: + continue + lines += ["", f"## {title}"] + for guidance in items: + suffix = f" [{guidance.cite}]" if guidance.cite else "" + lines.append(f"- {guidance.text}{suffix}") + return lines + + +def _sources_lines(plan: ProgramPlan, citations: Mapping[str, ResolvedCitation]) -> list[str]: + ids = [cid for cid in plan_citation_ids(plan) if cid in citations] + if not ids: + return [] + lines = ["", "## Sources"] + for number, cid in enumerate(ids, start=1): + resolved = citations[cid] + lines.append(f"{number}. {resolved.stars} {resolved.citation}") + return lines + + +def render_program( + plan: ProgramPlan, citations: Mapping[str, ResolvedCitation] | None = None +) -> str: + """Render a ProgramPlan to the human markdown view (deterministic). + + citations maps corpus ids to their resolved rendering; when provided the + advice/rationale sections and a final Sources section are emitted (the + Sources DOIs are what the Typst PDF bibliography picks up). + """ lines = _header_lines(plan) + lines += _guidance_lines(plan) for meso in plan.mesocycles: lines += ["", f"## Mesocycle {meso.index} — {meso.phase}"] for week in meso.weeks: lines.append("") lines.extend(_week_lines(week)) + if citations is not None: + lines += _sources_lines(plan, citations) return "\n".join(lines).strip() + "\n" diff --git a/src/performance_agent/programs/render_html.py b/src/performance_agent/programs/render_html.py index 1536262..04874ed 100644 --- a/src/performance_agent/programs/render_html.py +++ b/src/performance_agent/programs/render_html.py @@ -13,8 +13,10 @@ import html import re +from collections.abc import Mapping from performance_agent.engine import warmup_scheme +from performance_agent.evidence.citations import ResolvedCitation from performance_agent.exercises.dataset import DatasetExercise, ExerciseMediaIndex from performance_agent.memory.schemas import ( ExerciseBlock, @@ -22,7 +24,12 @@ SessionPlan, WeekPlan, ) -from performance_agent.programs.render import intensity_label, num_label, volume_label +from performance_agent.programs.render import ( + intensity_label, + num_label, + plan_citation_ids, + volume_label, +) _LABELS = { "en": { @@ -50,6 +57,9 @@ "volume_intensity": "Volume x{volume}, intensity x{intensity}", "set_targets": "Weekly set targets", "media_credit": "Exercise media & instructions", + "advice": "Advice", + "why_program": "Why this program", + "sources": "Sources", }, "fr": { "program": "Programme", @@ -76,6 +86,9 @@ "volume_intensity": "Volume x{volume}, intensité x{intensity}", "set_targets": "Cibles de séries hebdomadaires", "media_credit": "Médias & instructions des exercices", + "advice": "Conseils", + "why_program": "Pourquoi ce programme", + "sources": "Sources", }, "es": { "program": "Programa", @@ -102,6 +115,9 @@ "volume_intensity": "Volumen x{volume}, intensidad x{intensity}", "set_targets": "Series semanales objetivo", "media_credit": "Medios e instrucciones de los ejercicios", + "advice": "Consejos", + "why_program": "Por qué este programa", + "sources": "Fuentes", }, } @@ -159,6 +175,14 @@ details.fb dt { font-weight: 600; } details.fb dd { margin: 0 0 0.4rem; color: var(--muted); } ul.milestones { padding-left: 1.2rem; } +.guidance { margin: 0.75rem 0 0; } +.guidance h2 { margin: 0.75rem 0 0.25rem; font-size: 1rem; } +.guidance ul { margin: 0.25rem 0 0 1.1rem; padding: 0; } +.guidance li { margin: 0.2rem 0; } +sup.cite { color: var(--accent); font-weight: 600; } +section.sources ol { padding-left: 1.2rem; } +section.sources li { margin: 0.35rem 0; font-size: 0.9rem; } +section.sources .stars { color: var(--accent); letter-spacing: 0.05em; } footer.credit { margin-top: 2.5rem; color: var(--muted); font-size: 0.8rem; } @media print { details.week, details.tech, details.fb { open: true; } body { background: #fff; } } @@ -243,14 +267,19 @@ def _technique_html(record: DatasetExercise | None, locale: str) -> str: def _block_html( - session: SessionPlan, block: ExerciseBlock, catalog: _MediaCatalog, locale: str + session: SessionPlan, + block: ExerciseBlock, + catalog: _MediaCatalog, + locale: str, + numbers: dict[str, int], ) -> str: record, media_key = catalog.resolve(block) img = f'
    ' if media_key else "" name = html.escape(block.exercise) parts = [ f'
    {img}
    ', - f'

    {name} [{block.priority}]

    ', + f"

    {name}{_marker(numbers, block.cite)} " + f'[{block.priority}]

    ', _chips(block, locale), _warmup_html(session, block, locale), f'

    {html.escape(block.progression_rule)}

    ', @@ -277,13 +306,17 @@ def _fallbacks_html(session: SessionPlan, locale: str) -> str: ) -def _session_html(session: SessionPlan, catalog: _MediaCatalog, locale: str) -> str: +def _session_html( + session: SessionPlan, catalog: _MediaCatalog, locale: str, numbers: dict[str, int] +) -> str: days = _DAYS.get(locale, _DAYS["en"]) day = days[session.weekday] if session.weekday is not None else _t(locale, "unscheduled") qualities = ", ".join(session.qualities) if session.patterns: qualities += f" · {_t(locale, 'patterns')} : {', '.join(session.patterns)}" - blocks = "".join(_block_html(session, block, catalog, locale) for block in session.blocks) + blocks = "".join( + _block_html(session, block, catalog, locale, numbers) for block in session.blocks + ) return ( f'

    {html.escape(session.id)} — {day} · ' f"{session.est_minutes} min

    " @@ -292,7 +325,13 @@ def _session_html(session: SessionPlan, catalog: _MediaCatalog, locale: str) -> ) -def _week_html(week: WeekPlan, catalog: _MediaCatalog, locale: str, is_first: bool) -> str: +def _week_html( + week: WeekPlan, + catalog: _MediaCatalog, + locale: str, + is_first: bool, + numbers: dict[str, int], +) -> str: flags = [ _t(locale, flag) for flag, on in (("deload", week.is_deload), ("taper", week.is_taper)) @@ -309,7 +348,9 @@ def _week_html(week: WeekPlan, catalog: _MediaCatalog, locale: str, is_first: bo if week.weekly_set_targets: targets = ", ".join(f"{k}: {v}" for k, v in sorted(week.weekly_set_targets.items())) meta_lines.append(f"{_t(locale, 'set_targets')} : {targets}") - sessions = "".join(_session_html(session, catalog, locale) for session in week.sessions) + sessions = "".join( + _session_html(session, catalog, locale, numbers) for session in week.sessions + ) return ( f'
    ' f"{_t(locale, 'week')} {week.week_index}{suffix}" @@ -342,6 +383,53 @@ def _header_html(plan: ProgramPlan, locale: str) -> str: return f'
    {"".join(lines)}
    ' +def _marker(numbers: dict[str, int], cite: str | None) -> str: + if cite is None or cite not in numbers: + return "" + return f'[{numbers[cite]}]' + + +def _guidance_html(plan: ProgramPlan, numbers: dict[str, int], locale: str) -> str: + sections = [] + for key, emoji, items in ( + ("advice", "\U0001f48a", plan.advice), + ("why_program", "\U0001f52c", plan.rationale), + ): + if not items: + continue + rows = "".join(f"
  • {html.escape(g.text)}{_marker(numbers, g.cite)}
  • " for g in items) + sections.append(f"

    {emoji} {_t(locale, key)}

      {rows}
    ") + if not sections: + return "" + return f'
    {"".join(sections)}
    ' + + +def _sources_html( + citations: Mapping[str, ResolvedCitation], + numbers: dict[str, int], + locale: str, +) -> str: + ordered = sorted((cid for cid in numbers if cid in citations), key=lambda cid: numbers[cid]) + if not ordered: + return "" + rows = [] + for cid in ordered: + resolved = citations[cid] + link = ( + f' DOI' + if resolved.doi + else "" + ) + rows.append( + f'
  • {resolved.stars} ' + f"{html.escape(resolved.citation)}{link}
  • " + ) + return ( + f'

    \U0001f4da {_t(locale, "sources")}

    ' + f"
      {''.join(rows)}
    " + ) + + def _media_css(catalog: _MediaCatalog) -> str: """One background-image rule per GIF: dedup without any JavaScript.""" return "".join( @@ -350,21 +438,28 @@ def _media_css(catalog: _MediaCatalog) -> str: def render_program_html( - plan: ProgramPlan, locale: str = "en", index: ExerciseMediaIndex | None = None + plan: ProgramPlan, + locale: str = "en", + index: ExerciseMediaIndex | None = None, + citations: Mapping[str, ResolvedCitation] | None = None, ) -> str: """Render a ProgramPlan to a standalone, offline-ready HTML page. locale drives labels and exercise instructions (en/fr/es); index provides the media and is optional — without it the page still carries the full - prescription, just without GIFs and technique steps. + prescription, just without GIFs and technique steps. citations enables the + advice/rationale banner markers and the starred Sources bibliography. """ catalog = _MediaCatalog(index) + numbers = ( + {cid: i for i, cid in enumerate(plan_citation_ids(plan), start=1)} if citations else {} + ) sections = [] first = True for meso in plan.mesocycles: weeks = [] for week in meso.weeks: - weeks.append(_week_html(week, catalog, locale, first)) + weeks.append(_week_html(week, catalog, locale, first, numbers)) first = False sections.append( f"

    Mesocycle {meso.index} — {meso.phase}

    {''.join(weeks)}
    " @@ -382,6 +477,7 @@ def render_program_html( f'' '' f"{title}
    " - f"{_header_html(plan, locale)}{''.join(sections)}{credit}
    " + f"{_header_html(plan, locale)}{_guidance_html(plan, numbers, locale)}" + f"{''.join(sections)}{_sources_html(citations or {}, numbers, locale)}{credit}" "" ) diff --git a/tests/programs/test_render.py b/tests/programs/test_render.py index fcfcc6a..a3168e9 100644 --- a/tests/programs/test_render.py +++ b/tests/programs/test_render.py @@ -3,9 +3,11 @@ from datetime import date from pathlib import Path +from performance_agent.evidence.citations import ResolvedCitation from performance_agent.memory.schemas import ( ExerciseBlock, Fallbacks, + Guidance, Mesocycle, ProgramPlan, SessionPlan, @@ -13,6 +15,7 @@ WeekPlan, ) from performance_agent.programs.render import render_program +from tests.program_plans import minimal_plan GOLDEN = Path(__file__).parent / "golden_program.md" @@ -195,3 +198,31 @@ def test_pct_based_primary_gets_generic_warmup_note(): rendered = render_program(plan) # Front Squat is pct_1rm-based (no absolute load) → generic labeled ramp. assert "progressively heavier ramp sets" in rendered + + +def _md_guidance_plan(): + return minimal_plan( + advice=[Guidance(text="Creatine 5 g/day.", cite="id-a")], + rationale=[Guidance(text="12-16 hard sets per muscle per week.")], + ) + + +def test_markdown_renders_guidance_and_sources(): + citations = { + "id-a": ResolvedCitation( + citation="Kreider et al. (2017). ISSN position stand. DOI: 10.1186/s12970-017-0173-z.", + stars="★★★★★", + doi="10.1186/s12970-017-0173-z", + pmid=None, + ) + } + text = render_program(_md_guidance_plan(), citations=citations) + assert "## Advice" in text + assert "Creatine 5 g/day. [id-a]" in text + assert "## Sources" in text + assert "DOI: 10.1186/s12970-017-0173-z" in text + + +def test_markdown_without_citations_matches_legacy_output(): + text = render_program(minimal_plan()) + assert "## Sources" not in text and "## Advice" not in text diff --git a/tests/programs/test_render_html.py b/tests/programs/test_render_html.py index 55a6ba2..a92bede 100644 --- a/tests/programs/test_render_html.py +++ b/tests/programs/test_render_html.py @@ -1,7 +1,8 @@ import pytest +from performance_agent.evidence.citations import ResolvedCitation from performance_agent.exercises.dataset import ExerciseMediaIndex -from performance_agent.memory.schemas import ExerciseBlock, SessionPlan, WeekPlan +from performance_agent.memory.schemas import ExerciseBlock, Guidance, SessionPlan, WeekPlan from performance_agent.programs.render_html import render_program_html from tests.exercises.test_dataset import write_fixture_dataset from tests.program_plans import a_fallbacks, minimal_plan @@ -109,3 +110,53 @@ def test_html_escapes_user_text(): page = render_program_html(minimal_plan(note="")) assert "" not in page assert "<script>" in page + + +_CITATIONS = { + "id-a": ResolvedCitation( + citation="Kreider et al. (2017). ISSN position stand. DOI: 10.1186/s12970-017-0173-z.", + stars="★★★★★", + doi="10.1186/s12970-017-0173-z", + pmid=None, + ) +} + + +def _guidance_plan(): + session = SessionPlan( + id="w01-s1-lower-heavy", + weekday=0, + qualities=["strength_heavy"], + est_minutes=75, + purpose="Build the squat base", + blocks=[squat_block(cite="id-a")], + fallbacks=a_fallbacks(), + ) + week = WeekPlan(week_index=1, volume_factor=1.0, intensity_factor=0.9, sessions=[session]) + return minimal_plan( + mesocycles=[{"index": 1, "phase": "accumulation", "weeks": [week.model_dump()]}], + advice=[Guidance(text="Creatine 5 g/day.", cite="id-a")], + rationale=[Guidance(text="12-16 hard sets per muscle per week.")], + ) + + +def _bare_plan(): + return minimal_plan() + + +def test_html_renders_banner_markers_and_bibliography(): + page = render_program_html(_guidance_plan(), locale="en", citations=_CITATIONS) + assert "Advice" in page and "Creatine 5 g/day." in page + assert "[1]" in page # marker on the advice line and the citing block + assert "Sources" in page and "★★★★★" in page + assert "https://doi.org/10.1186/s12970-017-0173-z" in page + + +def test_html_without_guidance_or_citations_is_unchanged_shape(): + page = render_program_html(_bare_plan(), locale="en") + assert "Sources" not in page and "Advice" not in page + + +def test_html_localizes_banner_titles_in_french(): + page = render_program_html(_guidance_plan(), locale="fr", citations=_CITATIONS) + assert "Conseils" in page and "Pourquoi ce programme" in page From e0b692cdea9f54adfb203faca8c5ab9c8b4f6b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:04:11 +0200 Subject: [PATCH 15/22] Resolve and render plan citations at save time Co-Authored-By: Claude Fable 5 --- src/performance_agent/memory/store.py | 16 +++++++++++--- src/performance_agent/server/memory_tools.py | 21 +++++++++++++----- tests/server/test_memory_tools.py | 23 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/performance_agent/memory/store.py b/src/performance_agent/memory/store.py index f69c435..f00aaa8 100644 --- a/src/performance_agent/memory/store.py +++ b/src/performance_agent/memory/store.py @@ -5,7 +5,7 @@ """ import os -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import date from pathlib import Path @@ -13,6 +13,7 @@ import yaml from pydantic import ValidationError +from performance_agent.evidence.citations import ResolvedCitation from performance_agent.memory.documents import ensure_documentation_dir from performance_agent.memory.schemas import ( Calendar, @@ -485,6 +486,7 @@ def save_program( plan: ProgramPlan, reason: str | None = None, today: date | None = None, + citations: Mapping[str, ResolvedCitation] | None = None, ) -> tuple[Path, int]: """Validate, render, and atomically write a program as a yaml+md pair. @@ -494,7 +496,9 @@ def save_program( drift. Versions are immutable and never overwritten; adapting an existing program (v2+) requires a reason (the coaching-decision audit trail). The store owns version numbering — the plan's version/created_on/reason are - stamped here. + stamped here. citations maps the plan's corpus ids to their resolved + rendering; the server resolves them — None keeps the legacy citation-less + rendering for direct store users. """ current = latest_program_version(base_dir) version = 1 if current is None else current + 1 @@ -510,7 +514,13 @@ def save_program( "created_on": created.isoformat(), "reason": reason, } - content = "---\n" + _to_yaml(frontmatter) + "---\n\n" + render_program(stamped).strip() + "\n" + content = ( + "---\n" + + _to_yaml(frontmatter) + + "---\n\n" + + render_program(stamped, citations=citations).strip() + + "\n" + ) _atomic_write(yaml_path, _to_yaml(stamped.model_dump(mode="json"))) try: _atomic_write(md_path, content) diff --git a/src/performance_agent/server/memory_tools.py b/src/performance_agent/server/memory_tools.py index af98ea2..9db8397 100644 --- a/src/performance_agent/server/memory_tools.py +++ b/src/performance_agent/server/memory_tools.py @@ -5,6 +5,7 @@ adaptation through the versioned program store. """ +from collections.abc import Mapping from pathlib import Path from typing import Annotated, TypedDict @@ -12,6 +13,7 @@ from pydantic import Field from performance_agent.engine import SeasonModality +from performance_agent.evidence.citations import ResolvedCitation, resolve_citations from performance_agent.exercises.dataset import ExerciseMediaIndex from performance_agent.memory import diligence, monitoring, sequencing, store from performance_agent.memory import season as season_planner @@ -32,6 +34,7 @@ ) from performance_agent.memory.season import SeasonPlanView from performance_agent.memory.time_context import TimeContext, build_time_context +from performance_agent.programs.render import plan_citation_ids from performance_agent.programs.render_html import render_program_html @@ -271,7 +274,12 @@ def _doc_view(result: tuple[dict[str, object], str] | None, missing_msg: str) -> ) -def _write_program_html(base: Path, md_path: Path, version: int) -> str | None: +def _write_program_html( + base: Path, + md_path: Path, + version: int, + citations: Mapping[str, ResolvedCitation] | None = None, +) -> str | None: """Render and write the dated program .html next to the markdown (best-effort). The page always carries the full prescription; exercise GIFs and technique @@ -286,7 +294,7 @@ def _write_program_html(base: Path, md_path: Path, version: int) -> str | None: except (FileNotFoundError, NotADirectoryError): index = None locale = store.read_profile(base).locale - page = render_program_html(program.plan, locale=locale, index=index) + page = render_program_html(program.plan, locale=locale, index=index, citations=citations) html_path = md_path.with_suffix(".html") html_path.write_text(page, encoding="utf-8") return str(html_path) @@ -304,11 +312,14 @@ def save_program(plan: ProgramPlan, reason: str | None = None) -> ProgramSaved: on the plan. Files are named after the save date (program-YYYYMMDD.md, -2 on a same-day version). Alongside the markdown, a standalone .html session page is written (exercise GIFs + technique steps in the athlete's locale, - usable offline at the gym) — hand that file to the athlete. + usable offline at the gym) — hand that file to the athlete. Every cite on + advice/rationale/blocks must be a real corpus id — an unknown id aborts the + save before anything is written (anti-fabrication). """ base = resolve_athlete_dir() - path, version = store.save_program(base, plan, reason) - html_path = _write_program_html(base, path, version) + citations = resolve_citations(plan_citation_ids(plan)) + path, version = store.save_program(base, plan, reason, citations=citations) + html_path = _write_program_html(base, path, version, citations) return ProgramSaved(path=str(path), version=version, html_path=html_path) diff --git a/tests/server/test_memory_tools.py b/tests/server/test_memory_tools.py index cd64256..1e71346 100644 --- a/tests/server/test_memory_tools.py +++ b/tests/server/test_memory_tools.py @@ -5,6 +5,7 @@ import pytest +from performance_agent.evidence.corpus import load_corpus from tests.exercises.test_dataset import write_fixture_dataset from tests.program_plans import plan_dict @@ -495,3 +496,25 @@ async def test_read_readiness_empty_on_fresh_dir(client): result = await client.call_tool("read_readiness", {}) assert not result.isError assert result.structuredContent["readiness"] == [] + + +@pytest.mark.anyio +async def test_save_program_rejects_unknown_cite(client): + plan = plan_dict(goal_id="sub-45-10k") + plan["advice"] = [{"text": "Creatine 5 g/day.", "cite": "phantom-id"}] + result = await client.call_tool("save_program", {"plan": plan}) + assert result.isError + assert "phantom-id" in result.content[0].text + + +@pytest.mark.anyio +async def test_save_program_with_corpus_cite_renders_sources(client): + real_id = load_corpus()[0].id + plan = plan_dict(goal_id="sub-45-10k") + plan["advice"] = [{"text": "Backed advice.", "cite": real_id}] + saved = await client.call_tool("save_program", {"plan": plan}) + assert not saved.isError + read = await client.call_tool("read_program", {}) + assert "## Sources" in read.structuredContent["markdown"] + html_page = Path(saved.structuredContent["html_path"]).read_text(encoding="utf-8") + assert "Sources" in html_page From 10b8bb61fbe355e387794afb6dde19e8fcb9b28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:04:50 +0200 Subject: [PATCH 16/22] Add next-week-loads skill Co-Authored-By: Claude Fable 5 --- skills/next-week-loads/SKILL.md | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 skills/next-week-loads/SKILL.md diff --git a/skills/next-week-loads/SKILL.md b/skills/next-week-loads/SKILL.md new file mode 100644 index 0000000..987ed7a --- /dev/null +++ b/skills/next-week-loads/SKILL.md @@ -0,0 +1,41 @@ +--- +name: next-week-loads +description: Use when the athlete has logged their training week (or the loads_review + due action fires) and wants next week's working weights. Presents the engine's + deterministic suggestions block by block and never modifies the program. +tools: [read_athlete, get_time_context, read_program, read_sessions, + suggest_next_week_loads] +--- + +# Next week's loads + +The weekly review ritual: the athlete finished their training week, the logs are +in — hand them next week's numbers. All math is engine math: this skill quotes +`suggest_next_week_loads`, it never invents a load and it NEVER versions the +program (structure changes belong to program-adaptation). + +## Ritual + +1. Open with `read_athlete` + `get_time_context` (quote its dates, never compute + your own). `read_program` for the active version. +2. Call `suggest_next_week_loads`. Present the verdicts as a compact per-session + table in the athlete's locale: exercise, what they did, next load, and the + engine's rationale_key rendered as a sentence ("all sets at the top of the + range — +2.5 kg"). +3. Flags are conversations, not errors: + - `no_rule` — the block predates structured progression: agree the next load + with the athlete conversationally, from their logs (`read_sessions` for + context), and say plainly it is coaching judgment. + - `failed_sets` / `no_logged_sets` / `no_rir_logged` / `no_e1rm` / + `ambiguous_reps` — say what is missing and what would unlock the number. + - `clamped` — the autoregulated jump was capped at ±10% for safety; say so. + - `no_matched_week` — the logs don't map to any program week; ask what + actually happened this week. +4. Repeated `failed_sets` on the same exercise, pain mentions, or a stalled lift + are NOT solved here: name the signal and route to program-adaptation (and to + program-watch when the athlete wants the full audit). +5. `last_week` flag: the program just ended — route to training-checkin / + program-planning for what comes next. + +Numbers are quoted, never negotiated upward past the engine's suggestion; the +athlete can always choose LESS than suggested. From b5f7d186e148bed8a1dd78ccbae05be4eb4a952f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:04:50 +0200 Subject: [PATCH 17/22] Add program-watch skill Co-Authored-By: Claude Fable 5 --- skills/program-watch/SKILL.md | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 skills/program-watch/SKILL.md diff --git a/skills/program-watch/SKILL.md b/skills/program-watch/SKILL.md new file mode 100644 index 0000000..b19eb22 --- /dev/null +++ b/skills/program-watch/SKILL.md @@ -0,0 +1,49 @@ +--- +name: program-watch +description: Use every two weeks, at each mesocycle boundary, or on demand to audit + whether the running program is working exercise by exercise. Produces a keep / + watch / substitution-candidate verdict per exercise and never edits anything. +tools: [read_athlete, get_time_context, read_program, read_sessions, read_checkins, + compare_prescribed_actual, estimate_1rm, compute_weekly_loads, + compute_monotony_strain, score_exercises, get_citation, save_watch_report] +--- + +# Program watch + +The running program's auditor. Data only, per exercise — the question is never +"is the athlete tired" (training-checkin owns that) but "is THIS exercise doing +its job". Designed to run as a subagent launched by performance-coach or +training-checkin: audit silently, come back with a short report. + +## Signals, per exercise + +1. Open with `read_athlete` + `get_time_context` (quote its dates), then + `read_program`, `read_sessions`, `read_checkins` over the current mesocycle. +2. Trajectory — best sets per exercise through `estimate_1rm`: is the estimated + 1RM (or pace at heart rate, via compare_prescribed_actual, for endurance + blocks) moving the way the block intends? +3. Adherence — an exercise systematically skipped or cut short is a signal about + THAT exercise (friction, equipment, quiet pain), not laziness. +4. Pain — pain_flags and session notes that recur around one movement. +5. Chronic gap — compare_prescribed_actual: prescribed vs done, week after week. +6. Load shape — compute_weekly_loads + compute_monotony_strain when the pattern + suggests a structural problem (everything hard, nothing varied). + +## Verdict and report + +Per audited exercise: **keep** (working — say why in one line), **watch** (name +the signal and the check for next time), or **substitution candidate** (name the +signal, propose 1-2 replacements via score_exercises, cite corpus evidence with +get_citation when one backs the swap — otherwise label it coaching judgment). + +Write the report with save_watch_report (goal_id from the program; v2+ reason = +the trigger: "biweekly watch", "mesocycle boundary", "athlete request"). Keep it +short: verdicts first, data behind them after. + +## Hard boundary + +This skill NEVER edits the program, never prescribes, never substitutes in +place. Substitution candidates route to program-adaptation, which owns the +diagnosis conversation, the versioned save and the program-review gate. At a +mesocycle boundary, pair with deep-research's incremental watch: this report +says what to watch, the research says what the science says. From b4502b487e5dc8b21039cef12659393977737898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:06:54 +0200 Subject: [PATCH 18/22] Wire documents, mini-waves and weekly follow-up into the skills Co-Authored-By: Claude Fable 5 --- skills/athlete-onboarding/SKILL.md | 5 +++- skills/deep-research/SKILL.md | 37 ++++++++++++++++++++++- skills/next-week-loads/SKILL.md | 4 +-- skills/performance-coach/SKILL.md | 9 +++++- skills/program-adaptation/SKILL.md | 17 ++++++++++- skills/program-optimization/SKILL.md | 17 +++++++++++ skills/program-review/SKILL.md | 7 +++++ skills/training-checkin/SKILL.md | 13 ++++++++ tests/skills/test_structure.py | 44 ++++++++++++++++++++++++++++ 9 files changed, 147 insertions(+), 6 deletions(-) diff --git a/skills/athlete-onboarding/SKILL.md b/skills/athlete-onboarding/SKILL.md index 209bab4..0057f0d 100644 --- a/skills/athlete-onboarding/SKILL.md +++ b/skills/athlete-onboarding/SKILL.md @@ -96,5 +96,8 @@ insist more than once. Collect, in this order: ## Exit -Summarize what you stored (quote the profile back briefly), then route: new goal → +Summarize what you stored (quote the profile back briefly). Tell the athlete about +their `documentation/` folder (created with the profile): studies, physio or +medical reports, lab results, past programs dropped there are picked up +automatically at the next conversation. Then route: new goal → needs-analysis. Never skip the needs analysis on the way to a program. diff --git a/skills/deep-research/SKILL.md b/skills/deep-research/SKILL.md index 9597990..baf3edc 100644 --- a/skills/deep-research/SKILL.md +++ b/skills/deep-research/SKILL.md @@ -5,7 +5,8 @@ description: Use after a needs analysis has been saved and its goal accepted. Ru athlete, persists every verified study to the corpus, and writes the contradiction-aware research dossier the program will be built on. tools: [read_athlete, read_analysis, search_evidence, search_evidence_live, - save_evidence, verify_reference, check_citations, save_research_dossier] + save_evidence, verify_reference, check_citations, save_research_dossier, + list_athlete_documents, mark_document_processed] --- # Deep Research — le Chercheur @@ -17,6 +18,26 @@ that this research will take several minutes — set the expectation, don't leav them wondering why the coach has gone quiet. Narrate progress as you go ("wave 2 — periodization facet still thin") — the athlete should see the work. +## 0. The athlete's own documents — always first + +Before any online search, call `list_athlete_documents`. For every `new` or +`modified` file: read it (the tool hands you the absolute path; paginate large +PDFs). Then route it into exactly one lane and record it with +`mark_document_processed`: + +- **evidence** — ONLY when the document carries a DOI/PMID/ISBN that resolves + via `verify_reference`. Save it with `save_evidence` under the registry's + canonical title (you read the full text — conclusions may be richer than an + abstract-only entry), then mark with the corpus ids in `evidence_ids`. +- **context** — everything else (physio reports, lab results, past programs, + unverifiable PDFs): summarize what matters for coaching into `summary` and + `key_points`. It informs personalization and the facets below, but it is + NEVER cited as science in any deliverable. +- **unreadable** — corrupt or unopenable; mark it so you stop retrying. + +What the documents claim shapes the facets: a dropped study on a facet joins +that facet's evidence; a physio report adds a constraint facet. + ## 1. Read the brief Call `read_analysis` (latest version) — the needs analysis IS your brief: quality @@ -102,3 +123,17 @@ requires one). Quote the saved version and path, summarize coverage (facets covered vs thin, studies saved, languages searched), then route onward: dossier saved → program-planning (le Planificateur builds the skeleton on the dossier you just saved). + +## Mini-waves and the incremental watch + +A **mini-wave** is this protocol scoped to ONE question: corpus first, then 2-3 +live queries in English + the athlete's locale (+1 language if thin), same +verification and save rules, folded into the dossier as v+1 whose reason names +the trigger, with a "what changed vs v{N}" section. Program-adaptation runs +mini-waves for substantive triggers; run one directly when the athlete drops a +document or asks a question that touches one facet. + +The **incremental watch** (each mesocycle boundary, routed by training-checkin): +replay the dossier facets' queries with `year_from` set to the current dossier's +year — thin facets first. Something new → dossier v+1; nothing → no new version, +say so in one line. diff --git a/skills/next-week-loads/SKILL.md b/skills/next-week-loads/SKILL.md index 987ed7a..02b3ab9 100644 --- a/skills/next-week-loads/SKILL.md +++ b/skills/next-week-loads/SKILL.md @@ -11,8 +11,8 @@ tools: [read_athlete, get_time_context, read_program, read_sessions, The weekly review ritual: the athlete finished their training week, the logs are in — hand them next week's numbers. All math is engine math: this skill quotes -`suggest_next_week_loads`, it never invents a load and it NEVER versions the -program (structure changes belong to program-adaptation). +`suggest_next_week_loads`, it never invents a load, and it NEVER versions the program +(structure changes belong to program-adaptation). ## Ritual diff --git a/skills/performance-coach/SKILL.md b/skills/performance-coach/SKILL.md index b0758b8..5f403c9 100644 --- a/skills/performance-coach/SKILL.md +++ b/skills/performance-coach/SKILL.md @@ -3,7 +3,8 @@ name: performance-coach description: Use at the START of any coaching conversation about training, physical preparation, race goals, strength, or athlete follow-up. Establishes the session ritual, language, honesty and safety rules, and routes to the specialized skills. -tools: [read_athlete, get_time_context, list_due_actions, check_citations, search_evidence] +tools: [read_athlete, get_time_context, list_due_actions, list_athlete_documents, + check_citations, search_evidence] --- # PerformanceAgent Coach @@ -38,6 +39,8 @@ every fact comes from a performance-agent MCP tool. to log it. Frame it as the standard a real programme runs on, never as a blocker — you still coach fully on partial data; missing readiness is a follow-up, not a refusal. +6. `list_athlete_documents` — dropped files are messages: acknowledge new ones + and have them processed (deep-research §0) before they go stale. ## Honesty rules (non-negotiable) @@ -93,6 +96,10 @@ After a skill hands back: mandatory delivery gate; only its APPROVED verdict lets program-optimization — or program-adaptation for an adapted version — save and deliver) - Check-in shows poor adherence, plateau, pain, or schedule change → program-adaptation +- Training week logged, athlete wants next week's weights, or the loads_review + action fires → **next-week-loads**. +- Two weeks since the last audit, a mesocycle boundary, or "is this program + still right?" → **program-watch** (run it as a subagent; bring back verdicts). Re-evaluate routing after each skill completes. diff --git a/skills/program-adaptation/SKILL.md b/skills/program-adaptation/SKILL.md index 490be96..1f0317a 100644 --- a/skills/program-adaptation/SKILL.md +++ b/skills/program-adaptation/SKILL.md @@ -12,7 +12,8 @@ tools: [read_athlete, get_time_context, read_program, read_sessions, read_checki compute_monotony_strain, compute_fitness_fatigue, compute_readiness, recommend_deload, build_return_progression, search_evidence, search_evidence_live, save_evidence, verify_reference, - get_citation, check_citations, save_program] + get_citation, check_citations, save_program, + read_research_dossier, save_research_dossier] --- # Program Adaptation @@ -164,6 +165,20 @@ Never shame — diagnose the cause in the athlete's own words and re-scope: the reason "citation repair" and goes through §3 like every other proposal — it is not saved here. +## Research refresh — the mini-wave + +**Adapt first, research second**: tonight's session never waits for literature. +When the trigger is substantive — a confirmed plateau, recurring pain, a +calendar or method change — run a mini-wave AFTER the immediate fix is agreed: + +1. `read_research_dossier` for what the dossier already says on the question. +2. One question, 2-3 live queries (English + locale, +1 language if thin), the + deep-research verification and save rules unchanged. +3. Fold what you learned into the dossier with `save_research_dossier` (v+1, + reason = the trigger, with a "what changed" section). +4. If the finding contradicts the ACTIVE program, propose the sourced change to + the athlete through this skill's normal versioned flow — never edit silently. + ## 3. Confirm, then version - Present the proposed vN+1 and ASK the athlete to confirm before saving. diff --git a/skills/program-optimization/SKILL.md b/skills/program-optimization/SKILL.md index 7d363ce..091c226 100644 --- a/skills/program-optimization/SKILL.md +++ b/skills/program-optimization/SKILL.md @@ -74,6 +74,14 @@ render any corpus id you quote with `get_citation`. `prescribe_load` narrative may reference the profile's daily e1RM. Without VBT data, nothing changes — RIR/%1RM stays the path. +Every load-bearing block (load_kg, pct_1rm or rir intensity) carries a +STRUCTURED `progression` rule alongside the prose `progression_rule`: kind +`double` (rep_min/rep_max/increment_kg), `linear_load` (increment_kg), +`rir_target` (target_rir), `from_pct` (percent-planned weeks), or `none` +(pace/technique blocks). The prose is the human rendering of the structured +rule — write both from the same decision. This is what powers the weekly +loads review; a block left unstructured surfaces as `no_rule` every week. + ## 2b. Choose exercises from the ontology — scored, not free-hand Exercise selection is a scored decision, not authoring from memory. For each slot @@ -200,6 +208,15 @@ revision rounds on the same session with no resolution, stop looping — name th disagreement plainly and hand back to performance-coach instead of iterating a fourth time. +## 4b. Header guidance — advice and rationale + +Fill the plan's `advice` (nutrition, supplements, recovery — the frame's +numbers from read_nutrition_frame become athlete-facing lines here) and +`rationale` ("why this program" — the 3-5 decisions that shaped it). Every +line either carries a corpus `cite` (rendered with stars in the HTML header +and bibliography) or reads as coaching judgment. save_program refuses unknown +cite ids — never invent one. + ## 5. The gate, then save and deliver - **Nutrition annex:** call `read_nutrition_frame`. If a frame exists, quote diff --git a/skills/program-review/SKILL.md b/skills/program-review/SKILL.md index 699d1f6..7b0b863 100644 --- a/skills/program-review/SKILL.md +++ b/skills/program-review/SKILL.md @@ -86,6 +86,13 @@ Work the list in order; every item is pass/fail with the evidence quoted. `notes` — an unacknowledged `warn` is itself a fail (program-optimization is required to note the tradeoff, so a missing note means the check was skipped). +- Structured progression: every block prescribing load_kg, pct_1rm or rir has a + `progression` rule whose kind matches the prescription (a pct_1rm block with + kind=double is an objection); the prose progression_rule tells the same story. +- Guidance honesty: every advice/rationale line either cites a corpus id + (verify each with get_citation) or is phrased as coaching judgment; dosage + claims without a cite are an objection. + ## 3. Pass two — ADVERSARIAL second opinion Compliance proves the numbers; it does not prove the coaching. Run this pass diff --git a/skills/training-checkin/SKILL.md b/skills/training-checkin/SKILL.md index 71624ca..0ecc569 100644 --- a/skills/training-checkin/SKILL.md +++ b/skills/training-checkin/SKILL.md @@ -6,6 +6,7 @@ description: Use when a returning athlete with an active program shows up — or bodyweight drift off the nutrition frame, fixture pile-up), and routes to adaptation when triggers fire. tools: [read_athlete, get_time_context, read_program, log_checkin, log_session, + list_athlete_documents, log_readiness, read_readiness, read_sessions, read_checkins, read_nutrition_frame, compute_session_load, compute_monotony_strain, compute_fitness_fatigue, compute_acwr, compute_readiness, @@ -30,6 +31,9 @@ not the athlete names the problem. 1. Open by quoting `get_time_context`: "your last update was N days ago; W weeks to [goal]". If days_since_last_session is null, nothing was ever logged — say so and start logging today. +2a. `list_athlete_documents`: new or modified files in the drop folder are part + of the check-in — acknowledge them, and route to deep-research §0 to process + them (a physio report may change today's plan). 2. Backfill: call `read_sessions` for the window since last contact to see what's already logged, then ask which planned sessions are still missing. `log_session` each one the athlete reports — for strength sessions collect the structured @@ -186,6 +190,15 @@ fitness-fatigue time constants — read `usable` FIRST and treat an unusable fit "not enough data yet", never as a number to act on. Then route to program-adaptation so the next version is sized to the measured response. +## Mesocycle boundary duties + +When this check-in crosses into a new mesocycle (compare today against the +program's week boundaries): (1) route to deep-research's incremental watch — +replay the dossier facets with year_from = the dossier's year, thin facets +first; (2) route to program-watch for the per-exercise audit. The +loads_review and program_watch due actions surface both when overdue — treat +them like any other due action: open with them. + ## Route - Any trigger above → its named destination (program-adaptation, or diff --git a/tests/skills/test_structure.py b/tests/skills/test_structure.py index 7a6753c..0a1abb2 100644 --- a/tests/skills/test_structure.py +++ b/tests/skills/test_structure.py @@ -13,6 +13,8 @@ "program-report", "deep-research", "session-day", + "next-week-loads", + "program-watch", } @@ -167,6 +169,8 @@ def test_checkin_skill_protocol(skills): "recurring_fixtures", "refer out", "stop prescribing", + "list_athlete_documents", + "mesocycle boundary", ): assert needle in body, f"checkin skill lost: {needle}" @@ -189,6 +193,9 @@ def test_adaptation_skill_protocol(skills): "under-stimulus", "weekly_set_targets_for", "rir", + "mini-wave", + "read_research_dossier", + "adapt first", ): assert needle in body, f"adaptation skill lost: {needle}" @@ -214,6 +221,9 @@ def test_research_skill_protocol(skills): "contradiction", "thin evidence", "failed", + "list_athlete_documents", + "mini-wave", + "year_from", ): assert needle in body, f"deep-research skill lost: {needle}" @@ -281,3 +291,37 @@ def test_session_day_skill_protocol(skills): "hooper", ): assert needle in body, f"session-day skill lost: {needle}" + + +def test_next_week_loads_skill_protocol(skills): + loads = next(s for s in skills if s.frontmatter["name"] == "next-week-loads") + body = loads.body.casefold() + for needle in ( + "suggest_next_week_loads", + "read_program", + "never versions the program", + "no_rule", + "failed_sets", + "clamped", + "program-adaptation", + "coaching judgment", + ): + assert needle in body, f"next-week-loads skill lost: {needle}" + + +def test_program_watch_skill_protocol(skills): + watch = next(s for s in skills if s.frontmatter["name"] == "program-watch") + body = watch.body.casefold() + for needle in ( + "save_watch_report", + "compare_prescribed_actual", + "estimate_1rm", + "score_exercises", + "keep", + "watch", + "substitution candidate", + "never edits the program", + "program-adaptation", + "subagent", + ): + assert needle in body, f"program-watch skill lost: {needle}" From f0e8a7b1bd4d2d74dc7587e94c88b5d74466907d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:08:24 +0200 Subject: [PATCH 19/22] Document the living-evidence and weekly follow-up features Co-Authored-By: Claude Fable 5 --- README.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0714fe6..3be62b3 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,22 @@ tell you what you want to hear. PerformanceAgent is architected so neither is po is *fitted*: your own fitness-fatigue time constants, taper response and per-quality rates — with honesty gates that refuse a number when the data is too thin — planned across a 1-4 year macrocycle. +- **Athlete document drop folder** — drop studies, physio reports or past + programs into `documentation/`; verified studies join the evidence corpus + (full text read), everything else informs coaching as context, never faked + as science. +- **Research that stays alive** — targeted mini-waves on plateaus, injuries and + athlete questions; an incremental literature watch at every mesocycle + boundary (`year_from` delta queries), all folded into the versioned dossier. +- **Weekly loads review** — structured per-block progression rules computed by + the engine (`suggest_next_week_loads`): next week's exact weights from this + week's logs, flags instead of guesses. +- **Program watch** — a biweekly per-exercise audit (keep / watch / substitute + candidate) written as a versioned report; substitutions go through + program-adaptation, never silently. +- **Science on the gym page** — the offline program HTML opens with sourced + advice and "why this program" lines, `[n]` markers on blocks, and a starred + bibliography. ## Install (5 minutes, 3 steps) @@ -108,7 +124,7 @@ session *starts*: close any open session completely and run `claude` again. > List the performance-agent tools. ``` -You should see 93 tools. If so, you're done — just talk to it. +You should see 97 tools. If so, you're done — just talk to it. ## How to use it, step by step @@ -315,8 +331,8 @@ typed backward from the major event with per-year quality-emphasis budgets deriv from the gap priorities, feeding the season) and a training-residuals guard (warns where a maintained quality would decay past its Issurin retention window without a refresh); -1270 tests -incl. property-based) · 93 MCP +1339 tests +incl. property-based) · 97 MCP tools · file-based athlete memory with a season calendar, pre-session readiness logs, versioned machine-readable programs (structured plan + rendered markdown), a day-of adjustment log with escalation signals, a versioned individual response @@ -326,7 +342,7 @@ activity-file import (.fit/.tcx/.gpx/CSV, incl. power/cadence/splits and VBT exports) that proposes a session for the athlete to confirm before logging · DOI/PMID/ISBN-verified evidence corpus with anti-fabrication citation checks · live evidence search (PubMed, OpenAlex, Crossref, Semantic Scholar) -behind a double verification gate · twelve coaching skills incl. a mandatory delivery +behind a double verification gate · fourteen coaching skills incl. a mandatory delivery gate with an adversarial second opinion · Typst PDF reports (en/fr/es) behind a hard citation gate. From 3ae1d1df01e225bb04a03d7a944827f77b8f2b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:08:32 +0200 Subject: [PATCH 20/22] Check off completed plan tasks Co-Authored-By: Claude Fable 5 --- ...026-07-17-evidence-loop-weekly-followup.md | 176 +++++++++--------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md b/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md index 374fd40..2513eb4 100644 --- a/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md +++ b/docs/superpowers/plans/2026-07-17-evidence-loop-weekly-followup.md @@ -46,7 +46,7 @@ - Create: `src/performance_agent/memory/documents.py` - Test: `tests/memory/test_documents.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/memory/test_documents.py`: @@ -190,12 +190,12 @@ def test_corrupt_registry_is_rebuilt_empty(tmp_path): assert [item["filename"] for item in result["new"]] == ["study.pdf"] ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/memory/test_documents.py -q` Expected: FAIL with `ModuleNotFoundError: No module named 'performance_agent.memory.documents'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** Create `src/performance_agent/memory/documents.py`: @@ -432,12 +432,12 @@ def mark_processed( # noqa: PLR0913 -- one keyword per registry field, all name return record ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/memory/test_documents.py -q` Expected: PASS (10 passed) -- [ ] **Step 5: Lint, type-check, commit** +- [x] **Step 5: Lint, type-check, commit** ```bash uv run ruff check src/performance_agent/memory/documents.py tests/memory/test_documents.py @@ -455,7 +455,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/memory/store.py:103-108` (`write_profile`) - Test: `tests/memory/test_documents.py` (append) -- [ ] **Step 1: Write the failing test** (append to `tests/memory/test_documents.py`) +- [x] **Step 1: Write the failing test** (append to `tests/memory/test_documents.py`) ```python def test_write_profile_bootstraps_documentation_folder(tmp_path): @@ -466,9 +466,9 @@ def test_write_profile_bootstraps_documentation_folder(tmp_path): assert (tmp_path / DOCUMENTATION_DIR / README_FILE).exists() ``` -- [ ] **Step 2: Run it** — `uv run pytest tests/memory/test_documents.py::test_write_profile_bootstraps_documentation_folder -q` — Expected: FAIL (no documentation dir) +- [x] **Step 2: Run it** — `uv run pytest tests/memory/test_documents.py::test_write_profile_bootstraps_documentation_folder -q` — Expected: FAIL (no documentation dir) -- [ ] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the imports block (after the `from performance_agent.memory.schemas import (...)` import): +- [x] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the imports block (after the `from performance_agent.memory.schemas import (...)` import): ```python from performance_agent.memory.documents import ensure_documentation_dir @@ -490,9 +490,9 @@ def write_profile(base_dir: Path, profile: Profile) -> Path: (Keep the original body of `write_profile` otherwise identical to what is in the file — only the docstring line and the `ensure_documentation_dir` call are added.) -- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_documents.py tests/memory/ -q` — Expected: PASS, no regressions +- [x] **Step 4: Run** — `uv run pytest tests/memory/test_documents.py tests/memory/ -q` — Expected: PASS, no regressions -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/memory/store.py tests/memory/test_documents.py @@ -508,7 +508,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/server/app.py` - Test: `tests/server/test_document_tools.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/server/test_document_tools.py`: @@ -554,9 +554,9 @@ def test_mark_context_then_list_shows_processed(athlete_dir): assert inventory["processed"][0]["summary"].startswith("Physio") ``` -- [ ] **Step 2: Run** — `uv run pytest tests/server/test_document_tools.py -q` — Expected: FAIL (module missing) +- [x] **Step 2: Run** — `uv run pytest tests/server/test_document_tools.py -q` — Expected: FAIL (module missing) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** Create `src/performance_agent/server/document_tools.py`: @@ -666,9 +666,9 @@ and after `engine_tools.register(mcp)` line block add (keep existing order, appe document_tools.register(mcp) ``` -- [ ] **Step 4: Run** — `uv run pytest tests/server/test_document_tools.py tests/server/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/server/test_document_tools.py tests/server/ -q` — Expected: PASS -- [ ] **Step 5: Lint, commit** +- [x] **Step 5: Lint, commit** ```bash uv run ruff check src/performance_agent/server/document_tools.py src/performance_agent/server/app.py tests/server/test_document_tools.py && uv run ruff format --check src tests @@ -681,8 +681,8 @@ Co-Authored-By: Claude Fable 5 " ### Task 4: Phase 1 gate -- [ ] **Step 1: Full test suite** — Run: `uv run pytest -q` — Expected: all pass (1275+ tests, plus the new ones) -- [ ] **Step 2: Zero warnings** — Run: `uv run ruff check src tests && uv run ty check` — Expected: clean. Fix anything before moving on. +- [x] **Step 1: Full test suite** — Run: `uv run pytest -q` — Expected: all pass (1275+ tests, plus the new ones) +- [x] **Step 2: Zero warnings** — Run: `uv run ruff check src tests && uv run ty check` — Expected: clean. Fix anything before moving on. --- @@ -694,7 +694,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/memory/schemas.py` (insert `ProgressionRule` right BEFORE `class ExerciseBlock` at line 347; add one field to `ExerciseBlock`) - Test: `tests/memory/test_schemas_progression.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/memory/test_schemas_progression.py`: @@ -756,9 +756,9 @@ def test_block_accepts_structured_progression_and_stays_optional(): assert legacy.progression is None ``` -- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: ProgressionRule`) +- [x] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: ProgressionRule`) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** In `src/performance_agent/memory/schemas.py`, insert immediately before `class ExerciseBlock`: @@ -806,9 +806,9 @@ In `ExerciseBlock`, add after the `progression_rule: str = Field(min_length=1)` progression: ProgressionRule | None = None ``` -- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_schemas_progression.py tests/memory/ -q` — Expected: PASS (old program yaml without the field still loads: covered by existing store tests) +- [x] **Step 4: Run** — `uv run pytest tests/memory/test_schemas_progression.py tests/memory/ -q` — Expected: PASS (old program yaml without the field still loads: covered by existing store tests) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/memory/schemas.py tests/memory/test_schemas_progression.py @@ -823,7 +823,7 @@ Co-Authored-By: Claude Fable 5 " - Create: `src/performance_agent/engine/progression.py` - Test: `tests/engine/test_progression.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/engine/test_progression.py`: @@ -933,9 +933,9 @@ def test_from_pct_without_e1rm_flags(): assert "no_e1rm" in result.flags ``` -- [ ] **Step 2: Run** — `uv run pytest tests/engine/test_progression.py -q` — Expected: FAIL (module missing) +- [x] **Step 2: Run** — `uv run pytest tests/engine/test_progression.py -q` — Expected: FAIL (module missing) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** Create `src/performance_agent/engine/progression.py`: @@ -1050,9 +1050,9 @@ def next_load_from_pct( Note: `assert` here documents schema-guaranteed invariants (the Pydantic validator makes them unreachable); if the repo's ruff config rejects `S101`, replace each assert with an explicit `if x is None: raise ValueError(...)` guard. -- [ ] **Step 4: Run** — `uv run pytest tests/engine/test_progression.py tests/engine/test_engine_purity.py -q` — Expected: PASS (purity test must still pass — this module imports only schemas + dataclasses) +- [x] **Step 4: Run** — `uv run pytest tests/engine/test_progression.py tests/engine/test_engine_purity.py -q` — Expected: PASS (purity test must still pass — this module imports only schemas + dataclasses) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash uv run ruff check src/performance_agent/engine/progression.py tests/engine/test_progression.py @@ -1068,7 +1068,7 @@ Co-Authored-By: Claude Fable 5 " - Create: `src/performance_agent/memory/weekly_review.py` - Test: `tests/memory/test_weekly_review.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/memory/test_weekly_review.py`. It needs a small program fixture; build it inline: @@ -1233,9 +1233,9 @@ def test_no_matching_week_returns_empty_with_flag(tmp_path): assert "no_matched_week" in view["flags"] ``` -- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: FAIL (module missing) +- [x] **Step 2: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: FAIL (module missing) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** Create `src/performance_agent/memory/weekly_review.py`: @@ -1496,9 +1496,9 @@ def suggest_next_week_loads( return WeeklyLoadsView(week_matched=week.week_index, blocks=blocks, flags=flags) ``` -- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: PASS. If the Epley expectation in `test_from_pct_uses_next_week_pct_and_logged_e1rm` is off by one rounding step, recompute by hand (100 × (1 + 5/30) = 116.67; 0.85 × 116.67 = 99.17 → 100.0 at 2.5 rounding) — fix the TEST only if your arithmetic disagrees, never the engine. +- [x] **Step 4: Run** — `uv run pytest tests/memory/test_weekly_review.py -q` — Expected: PASS. If the Epley expectation in `test_from_pct_uses_next_week_pct_and_logged_e1rm` is off by one rounding step, recompute by hand (100 × (1 + 5/30) = 116.67; 0.85 × 116.67 = 99.17 → 100.0 at 2.5 rounding) — fix the TEST only if your arithmetic disagrees, never the engine. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash uv run ruff check src/performance_agent/memory/weekly_review.py tests/memory/test_weekly_review.py @@ -1514,7 +1514,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/memory/store.py` (constants + three functions, after the nutrition-frame block at the end of the file) - Test: `tests/memory/test_store_watch.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/memory/test_store_watch.py`: @@ -1547,9 +1547,9 @@ def test_latest_version_none_when_empty(tmp_path): assert store.latest_watch_report_version(tmp_path) is None ``` -- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_store_watch.py -q` — Expected: FAIL (`AttributeError: save_watch_report`) +- [x] **Step 2: Run** — `uv run pytest tests/memory/test_store_watch.py -q` — Expected: FAIL (`AttributeError: save_watch_report`) -- [ ] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the constants block (after `EXERCISE_LIBRARY_FILE`): +- [x] **Step 3: Implement** — in `src/performance_agent/memory/store.py`, add to the constants block (after `EXERCISE_LIBRARY_FILE`): ```python WATCH_DIR = "watch" @@ -1601,9 +1601,9 @@ def read_watch_report( ) ``` -- [ ] **Step 4: Run** — `uv run pytest tests/memory/test_store_watch.py tests/memory/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/memory/test_store_watch.py tests/memory/ -q` — Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/memory/store.py tests/memory/test_store_watch.py @@ -1619,7 +1619,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/server/app.py` - Test: `tests/server/test_followup_tools.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/server/test_followup_tools.py`: @@ -1721,9 +1721,9 @@ def test_save_watch_report_versions(athlete_dir): assert result["version"] == 1 ``` -- [ ] **Step 2: Run** — `uv run pytest tests/server/test_followup_tools.py -q` — Expected: FAIL (module missing) +- [x] **Step 2: Run** — `uv run pytest tests/server/test_followup_tools.py -q` — Expected: FAIL (module missing) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** Create `src/performance_agent/server/followup_tools.py`: @@ -1779,9 +1779,9 @@ def register(mcp: FastMCP) -> None: In `src/performance_agent/server/app.py`, add `followup_tools` to the import tuple (alphabetical: between `exercise_tools` and `import_tools`) and add `followup_tools.register(mcp)` after `document_tools.register(mcp)`. -- [ ] **Step 4: Run** — `uv run pytest tests/server/test_followup_tools.py tests/server/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/server/test_followup_tools.py tests/server/ -q` — Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash uv run ruff check src/performance_agent/server/followup_tools.py src/performance_agent/server/app.py tests/server/test_followup_tools.py @@ -1798,7 +1798,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/memory/diligence.py` (fact extraction) - Test: `tests/engine/test_diligence.py` (append), `tests/memory/` covered via server tests -- [ ] **Step 1: Write the failing tests** (append to `tests/engine/test_diligence.py`) +- [x] **Step 1: Write the failing tests** (append to `tests/engine/test_diligence.py`) ```python def test_loads_review_due_when_sessions_logged_and_never_reviewed(): @@ -1869,9 +1869,9 @@ def test_program_watch_quiet_when_recent_or_no_program(): assert "program_watch" not in kinds ``` -- [ ] **Step 2: Run** — `uv run pytest tests/engine/test_diligence.py -q` — Expected: FAIL (`unexpected keyword argument 'sessions_logged_last_week'`) +- [x] **Step 2: Run** — `uv run pytest tests/engine/test_diligence.py -q` — Expected: FAIL (`unexpected keyword argument 'sessions_logged_last_week'`) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** In `src/performance_agent/engine/diligence.py`: @@ -1974,9 +1974,9 @@ And extend `_build_facts`'s returned `DiligenceFacts(...)` with: Also update the `list_due_actions` tool docstring in `src/performance_agent/server/memory_tools.py` (lines 564-577): extend the surfaced list with `a finished training week that never got its loads review, and a program unaudited for two weeks (program watch)`. -- [ ] **Step 4: Run** — `uv run pytest tests/engine/test_diligence.py tests/memory/ tests/server/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/engine/test_diligence.py tests/memory/ tests/server/ -q` — Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/engine/diligence.py src/performance_agent/memory/diligence.py src/performance_agent/server/memory_tools.py tests/engine/test_diligence.py @@ -1987,7 +1987,7 @@ Co-Authored-By: Claude Fable 5 " ### Phase 2 gate -- [ ] Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: all green, zero warnings. +- [x] Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: all green, zero warnings. --- @@ -1999,7 +1999,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/memory/schemas.py` (insert `Guidance` right BEFORE `class ProgramPlan` at line ~456; add two fields to `ProgramPlan`) - Test: `tests/memory/test_schemas_progression.py` (append) -- [ ] **Step 1: Write the failing test** (append to `tests/memory/test_schemas_progression.py`) +- [x] **Step 1: Write the failing test** (append to `tests/memory/test_schemas_progression.py`) ```python def test_program_plan_carries_optional_guidance(): @@ -2054,9 +2054,9 @@ def test_program_plan_carries_optional_guidance(): assert bare.advice == [] ``` -- [ ] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: Guidance`) +- [x] **Step 2: Run** — `uv run pytest tests/memory/test_schemas_progression.py -q` — Expected: FAIL (`ImportError: Guidance`) -- [ ] **Step 3: Implement** — in `src/performance_agent/memory/schemas.py`, insert before `class ProgramPlan`: +- [x] **Step 3: Implement** — in `src/performance_agent/memory/schemas.py`, insert before `class ProgramPlan`: ```python class Guidance(BaseModel): @@ -2079,9 +2079,9 @@ and add to `ProgramPlan` after `test_milestones`: rationale: list[Guidance] = Field(default_factory=list) ``` -- [ ] **Step 4: Run** — `uv run pytest tests/memory/ -q` — Expected: PASS (old plan.yaml files load: both fields default) +- [x] **Step 4: Run** — `uv run pytest tests/memory/ -q` — Expected: PASS (old plan.yaml files load: both fields default) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/memory/schemas.py tests/memory/test_schemas_progression.py @@ -2097,7 +2097,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/programs/render.py` (add `plan_citation_ids` only, rendering comes in Task 13) - Test: `tests/evidence/test_citations_resolution.py` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/evidence/test_citations_resolution.py`: @@ -2187,9 +2187,9 @@ def test_plan_citation_ids_orders_and_dedupes(): assert plan_citation_ids(plan) == ["id-a", "id-b", "id-c"] ``` -- [ ] **Step 2: Run** — `uv run pytest tests/evidence/test_citations_resolution.py -q` — Expected: FAIL (`ImportError: resolve_citations`) +- [x] **Step 2: Run** — `uv run pytest tests/evidence/test_citations_resolution.py -q` — Expected: FAIL (`ImportError: resolve_citations`) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** In `src/performance_agent/evidence/citations.py`, add imports for `dataclass` and the corpus loader, then append: @@ -2266,9 +2266,9 @@ def plan_citation_ids(plan: ProgramPlan) -> list[str]: return ids ``` -- [ ] **Step 4: Run** — `uv run pytest tests/evidence/ tests/programs/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/evidence/ tests/programs/ -q` — Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/performance_agent/evidence/citations.py src/performance_agent/programs/render.py tests/evidence/test_citations_resolution.py @@ -2284,7 +2284,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/programs/render_html.py` - Test: `tests/programs/test_render.py`, `tests/programs/test_render_html.py` (append) -- [ ] **Step 1: Write the failing tests** (append to `tests/programs/test_render_html.py`; reuse that file's existing plan fixture/helpers if one exists, otherwise build the same minimal plan as in Task 12's test with `advice=[Guidance(text="Creatine 5 g/day.", cite="id-a")]`, one block citing `"id-a"`, and `citations={"id-a": ResolvedCitation(citation="Kreider et al. (2017). ISSN position stand.", stars="★★★★★", doi="10.1186/s12970-017-0173-z", pmid=None)}` — import `ResolvedCitation` from `performance_agent.evidence.citations`): +- [x] **Step 1: Write the failing tests** (append to `tests/programs/test_render_html.py`; reuse that file's existing plan fixture/helpers if one exists, otherwise build the same minimal plan as in Task 12's test with `advice=[Guidance(text="Creatine 5 g/day.", cite="id-a")]`, one block citing `"id-a"`, and `citations={"id-a": ResolvedCitation(citation="Kreider et al. (2017). ISSN position stand.", stars="★★★★★", doi="10.1186/s12970-017-0173-z", pmid=None)}` — import `ResolvedCitation` from `performance_agent.evidence.citations`): ```python def test_html_renders_banner_markers_and_bibliography(): @@ -2334,9 +2334,9 @@ def test_markdown_without_citations_matches_legacy_output(): (define `_guidance_plan()` / `_bare_plan()` helpers once at the bottom of each test file — same construction as Task 12's test plan, with and without advice/rationale/cites) -- [ ] **Step 2: Run** — `uv run pytest tests/programs/ -q` — Expected: FAIL (unexpected keyword `citations`) +- [x] **Step 2: Run** — `uv run pytest tests/programs/ -q` — Expected: FAIL (unexpected keyword `citations`) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** `src/performance_agent/programs/render.py`: @@ -2489,9 +2489,9 @@ def render_program_html( Inside, before the mesocycle loop compute `numbers = {cid: i for i, cid in enumerate(plan_citation_ids(plan), start=1)} if citations else {}`, pass `numbers` to `_week_html`, insert `_guidance_html(plan, numbers, locale)` right after `_header_html(plan, locale)` in the final f-string, and insert `_sources_html(plan, citations or {}, numbers, locale)` between the sections and `credit`. -- [ ] **Step 4: Run** — `uv run pytest tests/programs/ -q` — Expected: PASS, including the pre-existing golden test (bare plans render byte-identically). +- [x] **Step 4: Run** — `uv run pytest tests/programs/ -q` — Expected: PASS, including the pre-existing golden test (bare plans render byte-identically). -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash uv run ruff check src/performance_agent/programs tests/programs @@ -2508,7 +2508,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `src/performance_agent/server/memory_tools.py` (`save_program`, `_write_program_html`) - Test: `tests/server/test_memory_tools.py` (append) -- [ ] **Step 1: Write the failing tests** (append to `tests/server/test_memory_tools.py`, reusing that file's existing athlete-dir fixture pattern — it already tests `save_program`; follow its plan-building helper. New tests): +- [x] **Step 1: Write the failing tests** (append to `tests/server/test_memory_tools.py`, reusing that file's existing athlete-dir fixture pattern — it already tests `save_program`; follow its plan-building helper. New tests): ```python def test_save_program_rejects_unknown_cite(athlete_dir): @@ -2536,9 +2536,9 @@ def test_save_program_with_corpus_cite_renders_sources(athlete_dir): (adapt helper names to what the file actually defines; import `Guidance` and `Path` at the top with the file's imports) -- [ ] **Step 2: Run** — `uv run pytest tests/server/test_memory_tools.py -q` — Expected: FAIL (no Sources section / no rejection) +- [x] **Step 2: Run** — `uv run pytest tests/server/test_memory_tools.py -q` — Expected: FAIL (no Sources section / no rejection) -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** `src/performance_agent/memory/store.py` — extend `save_program`: @@ -2595,9 +2595,9 @@ def save_program(plan: ProgramPlan, reason: str | None = None) -> ProgramSaved: and extend its docstring with: `Every cite on advice/rationale/blocks must be a real corpus id — an unknown id aborts the save before anything is written (anti-fabrication).` -- [ ] **Step 4: Run** — `uv run pytest tests/server/ tests/memory/ tests/programs/ -q` — Expected: PASS +- [x] **Step 4: Run** — `uv run pytest tests/server/ tests/memory/ tests/programs/ -q` — Expected: PASS -- [ ] **Step 5: Commit + phase gate** +- [x] **Step 5: Commit + phase gate** ```bash uv run pytest -q && uv run ruff check src tests && uv run ty check @@ -2616,7 +2616,7 @@ Co-Authored-By: Claude Fable 5 " **Files:** - Create: `skills/next-week-loads/SKILL.md` -- [ ] **Step 1: Write the skill** +- [x] **Step 1: Write the skill** ```markdown --- @@ -2662,9 +2662,9 @@ Numbers are quoted, never negotiated upward past the engine's suggestion; the athlete can always choose LESS than suggested. ``` -- [ ] **Step 2: Verify structure** — Run: `uv run pytest tests/skills/ -q` — Expected: FAIL on `test_all_expected_skills_exist` (new skill not in EXPECTED_SKILLS — fixed in Task 17; the OTHER skills tests must not fail). If `test_tool_references` fails here, a declared tool name is wrong — fix the frontmatter now. +- [x] **Step 2: Verify structure** — Run: `uv run pytest tests/skills/ -q` — Expected: FAIL on `test_all_expected_skills_exist` (new skill not in EXPECTED_SKILLS — fixed in Task 17; the OTHER skills tests must not fail). If `test_tool_references` fails here, a declared tool name is wrong — fix the frontmatter now. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add skills/next-week-loads/SKILL.md @@ -2678,7 +2678,7 @@ Co-Authored-By: Claude Fable 5 " **Files:** - Create: `skills/program-watch/SKILL.md` -- [ ] **Step 1: Write the skill** +- [x] **Step 1: Write the skill** ```markdown --- @@ -2732,9 +2732,9 @@ mesocycle boundary, pair with deep-research's incremental watch: this report says what to watch, the research says what the science says. ``` -- [ ] **Step 2: Verify** — Run: `uv run pytest tests/skills/test_tool_references.py -q` — Expected: only `test_all_expected_skills_exist`-style failures from test_structure (fixed next task); tool references must PASS for this file. +- [x] **Step 2: Verify** — Run: `uv run pytest tests/skills/test_tool_references.py -q` — Expected: only `test_all_expected_skills_exist`-style failures from test_structure (fixed next task); tool references must PASS for this file. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add skills/program-watch/SKILL.md @@ -2749,7 +2749,7 @@ Co-Authored-By: Claude Fable 5 " - Modify: `skills/deep-research/SKILL.md`, `skills/program-adaptation/SKILL.md`, `skills/training-checkin/SKILL.md`, `skills/performance-coach/SKILL.md`, `skills/athlete-onboarding/SKILL.md`, `skills/program-optimization/SKILL.md`, `skills/program-review/SKILL.md` - Modify: `tests/skills/test_structure.py` -- [ ] **Step 1: Update `tests/skills/test_structure.py` first (failing invariants drive the edits)** +- [x] **Step 1: Update `tests/skills/test_structure.py` first (failing invariants drive the edits)** Add to `EXPECTED_SKILLS`: `"next-week-loads", "program-watch",` @@ -2797,7 +2797,7 @@ And extend three existing protocol tests with new needles (append to each tuple) Run: `uv run pytest tests/skills/ -q` — Expected: FAIL on exactly these invariants (that's the worklist). -- [ ] **Step 2: `skills/deep-research/SKILL.md`** +- [x] **Step 2: `skills/deep-research/SKILL.md`** Frontmatter: add `list_athlete_documents, mark_document_processed, verify_reference` to `tools` (keep existing entries; `verify_reference` is already declared — do not duplicate). @@ -2843,7 +2843,7 @@ year — thin facets first. Something new → dossier v+1; nothing → no new ve say so in one line. ``` -- [ ] **Step 3: `skills/program-adaptation/SKILL.md`** +- [x] **Step 3: `skills/program-adaptation/SKILL.md`** Frontmatter: add `read_research_dossier, save_research_dossier` to `tools`. @@ -2865,7 +2865,7 @@ calendar or method change — run a mini-wave AFTER the immediate fix is agreed: the athlete through this skill's normal versioned flow — never edit silently. ``` -- [ ] **Step 4: `skills/training-checkin/SKILL.md`** +- [x] **Step 4: `skills/training-checkin/SKILL.md`** Frontmatter: add `list_athlete_documents` to `tools`. @@ -2890,7 +2890,7 @@ loads_review and program_watch due actions surface both when overdue — treat them like any other due action: open with them. ``` -- [ ] **Step 5: `skills/performance-coach/SKILL.md`** +- [x] **Step 5: `skills/performance-coach/SKILL.md`** Frontmatter: add `list_athlete_documents` to `tools`. @@ -2910,7 +2910,7 @@ In the routing table/section, add two rows/lines: still right?" → **program-watch** (run it as a subagent; bring back verdicts). ``` -- [ ] **Step 6: `skills/athlete-onboarding/SKILL.md`** +- [x] **Step 6: `skills/athlete-onboarding/SKILL.md`** Add one line to the closing/wrap-up section (no tool changes — write_profile creates the folder): @@ -2920,7 +2920,7 @@ profile): studies, physio or medical reports, lab results, past programs dropped there are picked up automatically at the next conversation. ``` -- [ ] **Step 7: `skills/program-optimization/SKILL.md`** +- [x] **Step 7: `skills/program-optimization/SKILL.md`** In the block-construction section (where per-block fields are specified), add: @@ -2945,7 +2945,7 @@ and bibliography) or reads as coaching judgment. save_program refuses unknown cite ids — never invent one. ``` -- [ ] **Step 8: `skills/program-review/SKILL.md`** +- [x] **Step 8: `skills/program-review/SKILL.md`** Add to the deterministic compliance checklist: @@ -2958,9 +2958,9 @@ Add to the deterministic compliance checklist: claims without a cite are an objection. ``` -- [ ] **Step 9: Run the full skills suite** — `uv run pytest tests/skills/ -q` — Expected: PASS (structure, tool references, protocols, no fabricated refs). If `test_bodies_do_not_reference_undeclared_tools` fails, a body mentions a tool missing from its frontmatter — add it to `tools`. +- [x] **Step 9: Run the full skills suite** — `uv run pytest tests/skills/ -q` — Expected: PASS (structure, tool references, protocols, no fabricated refs). If `test_bodies_do_not_reference_undeclared_tools` fails, a body mentions a tool missing from its frontmatter — add it to `tools`. -- [ ] **Step 10: Commit** +- [x] **Step 10: Commit** ```bash git add skills/ tests/skills/test_structure.py @@ -2974,7 +2974,7 @@ Co-Authored-By: Claude Fable 5 " **Files:** - Modify: `README.md` -- [ ] **Step 1: Update README** — locate the tool count (search for `93`) and update to **97**; in the feature list add four bullets in the repo's existing bullet style: +- [x] **Step 1: Update README** — locate the tool count (search for `93`) and update to **97**; in the feature list add four bullets in the repo's existing bullet style: ```markdown - **Athlete document drop folder** — drop studies, physio reports or past @@ -2995,9 +2995,9 @@ Co-Authored-By: Claude Fable 5 " bibliography. ``` -- [ ] **Step 2: Full suite + linters** — Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: everything green, zero warnings. +- [x] **Step 2: Full suite + linters** — Run: `uv run pytest -q && uv run ruff check src tests && uv run ty check` — Expected: everything green, zero warnings. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add README.md @@ -3006,7 +3006,7 @@ git commit -m "Document the living-evidence and weekly follow-up features Co-Authored-By: Claude Fable 5 " ``` -- [ ] **Step 4: NOT in this plan** — version bump, CHANGELOG, PyPI release, and the pre-competition spec are separate work. +- [x] **Step 4: NOT in this plan** — version bump, CHANGELOG, PyPI release, and the pre-competition spec are separate work. --- From 44d261235e331915a52642ca857dfeeaefc57c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:12:01 +0200 Subject: [PATCH 21/22] Bump version to 0.8.0 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5789f8c..3a9ff75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to PerformanceAgent. Versions follow the git tags. +## 0.8.0 — Living Evidence & Weekly Follow-up + +### Added + +- **Athlete document drop folder** — every athlete directory gets a + `documentation/` folder (created at onboarding); drop studies, physio reports, + lab results or past programs there and the coach picks them up at the next + conversation. Verified studies (DOI/PMID/ISBN) join the evidence corpus; + everything else informs coaching as context, never presented as science. + New tools: `list_athlete_documents`, `mark_document_processed`. +- **Weekly loads review** — exercise blocks now carry a structured `progression` + rule (double progression, linear load, RIR target, %1RM, none) and the new + `suggest_next_week_loads` tool computes next week's exact working weights from + the logged week — deterministic engine math with flags instead of guesses + (`no_rule`, `failed_sets`, `clamped`, `no_matched_week`, …). New skill: + `next-week-loads`. The review never modifies the program. +- **Program watch** — a biweekly per-exercise audit (keep / watch / substitution + candidate) written as an immutable versioned report under `watch/` via + `save_watch_report`; substitutions route through program-adaptation, never + silently. New skill: `program-watch`. `list_due_actions` now surfaces an + overdue loads review and an unaudited program. +- **Science on the gym page** — programs can carry cited `advice` and + `rationale` lines; the markdown and offline HTML open with them and close + with a starred bibliography (`[n]` markers on citing blocks, DOI links). + `save_program` refuses any citation id not in the evidence corpus. +- **Research that stays alive** — deep-research gains document intake (step 0), + targeted mini-waves on plateaus/injuries/athlete questions, and an + incremental literature watch (`year_from` delta queries) at every mesocycle + boundary, all folded into the versioned dossier. + ## 0.7.0 — Dated Program Filenames ### Changed diff --git a/pyproject.toml b/pyproject.toml index ad614aa..b05a983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "performance-agent" -version = "0.7.0" +version = "0.8.0" description = "Evidence-based AI physical preparation platform — backend" readme = "README.md" requires-python = ">=3.13" From 1e13fc08d6cae5a9ecbebf79e1e2140a6a1fc027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Rieux?= Date: Fri, 17 Jul 2026 22:12:37 +0200 Subject: [PATCH 22/22] Update lockfile for 0.8.0 Co-Authored-By: Claude Fable 5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index d4f0a9a..8f88342 100644 --- a/uv.lock +++ b/uv.lock @@ -367,7 +367,7 @@ wheels = [ [[package]] name = "performance-agent" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "fitdecode" },