runlab-report
Render the full training overview — where the athlete stands, what the data supports, what it does not, and what to do next — as an audited HTML and Markdown report. Use when the user asks for "the bi
Files1
View on GitHubSKILL.md.claude/skills/runlab-report/SKILL.md
GitHubrunlab report
This is the main deliverable. It is not a document you write; it is a document the renderer assembles from three separate things:
| Part | Who produces it | What it holds |
|---|---|---|
| the spec | scripts/report.py, from its analysis catalogue |
which numbers appear where, and which slots need a sentence |
| the result files | scripts/report.py, one per analysis it ran |
the numbers themselves |
| the interpretation store | you | the sentences, each bound to the data it was written for |
The split is the mechanism. You cannot put a number in the report, because your part of the file has no numeric fields. The renderer refuses to emit a number that no result file contains, and it marks a sentence as stale when the data behind it changed after the sentence was written.
Read /runlab:runlab-methodology first if it is not loaded. The evidence grades
below come from there.
Standing rules
- Everything runs through the shim:
bash "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rl-python.sh" "${CLAUDE_PLUGIN_ROOT}/scripts/<script>.py" [args] - Before relying on a flag, run the script with
--help. If it disagrees with what is written here, follow--helpand tell the user the skill is out of date. Never work around a missing script by computing the number yourself.
Step 1 — the state of the data
bash "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rl-python.sh" "${CLAUDE_PLUGIN_ROOT}/scripts/read-db.py" --section computed
read-db.py always prints JSON; there is no --json flag. Useful sections,
each passable to --section (repeatable, dotted):
| Section | What it settles |
|---|---|
computed.activities |
how many runs, over what span, how old the cache is |
computed.gates |
per analysis: what it requires, what is there, what is short |
computed.comparison_base |
whether comparison against the athlete's own history is possible at all |
computed.data_quality |
channel coverage, always as n of n_total |
computed.anchors |
per anchor: zone_statement_allowed, requires_uncertainty |
computed.patterns_open, computed.tests_due |
what is open and what is overdue |
problems |
state files that failed validation — deal with these first |
Any gate whose missing[] is non-empty is a section the report will show as an
explicit gap rather than omit. shortfall is the number to quote: "three more
long runs", not "not enough data". This step is orientation — step 2 applies the
same gates itself and reports what it did with them — so skip it when the user
only wants the document.
Step 2 — build the whole report in one call
bash "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rl-python.sh" "${CLAUDE_PLUGIN_ROOT}/scripts/report.py" \
--lang de,en --out <data root>/reports --json
That one command is the whole pipeline: it walks the analysis catalogue, checks
each section's data precondition, computes the ones that pass, files every answer
as a result file, builds the spec, and renders HTML and Markdown per language.
--dry-run reports the plan without writing anything; --list-sections prints
the catalogue; --sections <ids> restricts it; --since <date> narrows the
window.
Exit codes: 0 a report · 1 a section whose gate opened could not be computed
· 2 a number has no source · 3 the render was not reproducible, or
--strict-text and a slot is unfilled · 4 misuse.
Read three things out of the payload, in this order:
| Field | What to do with it |
|---|---|
sections[] |
each carries state (present or gap), reason, n, result_ids, missing[]. Report the gaps to the user with their shortfall: "zones needs one heart-rate anchor" — never "not enough data". |
render.<lang>.audit |
n_unresolved and n_literals must both be zero. They are, or the exit code was 2 and something is wrong with the spec, not with your prose. |
interpretation.slots[] |
the sentences you owe. |
For a history-wide scan the report does not cover — hunting correlations,
proposing new patterns — delegate to the runlab-history subagent. It returns a
condensed proposal; the decision stays here.
Step 3 — write the interpretations you owe
interpretation.slots[] lists every unfilled and every stale slot, and each entry
carries the payload that fills it. Merge the store_fragment objects into one
file, by convention <data root>/reports/interpretations.json (the reports/
tree is writable; state/, profile.json and cache/results/ are not):
{
"interpretations": {
"fatigue.decoupling.claim": {
"en": {"text": "Efficiency holds until the second hour, then falls steadily.",
"interpretation_for": "sha256:50e4248273..."}
}
}
}
textis prose. It carries the meaning, not the measurement — a digit in it is a number nobody can check, and the audit reports it as one. The figures are substituted by the renderer from the result files.interpretation_foris the hash the slot entry gave you, and it is what makes the sentence go stale when the data behind it moves. Take it from the payload; do not invent it.- A slot that sits on a finding card also carries
update_db_fragment: the same sentence as a durable finding forupdate-db.py, already citing that card's result id and the metric keys the file really contains. Offer it in step 5.
Then run step 2 again with --interpretations <store.json>; the slots you filled
render as sentences and the rest still say what they are missing. Add
--strict-text when you want an unfilled slot to be an error rather than a
marker.
Step 4 — when you need the renderer directly
report.py calls render.py for you. Reach for the renderer itself only to
re-render an existing spec — for example after editing the interpretation store by
hand:
bash "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rl-python.sh" "${CLAUDE_PLUGIN_ROOT}/scripts/render.py" \
--spec <data root>/reports/report.spec.json --root <data root> \
--interpretations <store.json> --lang en --out <data root>/reports \
--check --strict-text --verify
Its exit codes: 0 clean · 1 usage or input error · 2 a number has no
source · 3 an interpretation is missing or stale, or the render is not
deterministic. --slots lists slot keys and their hashes, --bind <file> fills
an empty interpretation_for (never one that differs).
Never remove --check to make a report come out. Exit 2 means the document
wanted to state something no analysis produced, and the fix is to run the
analysis or drop the claim. The audit block names each one under unresolved[]
with the result_id and the metric path it wanted.
What the finished report must contain
- Masthead — the data basis in one line: number of runs, date range, how many carry mechanics, how many carry heart rate, and a single as-of stamp. Never mix two.
- Key tiles — the steering numbers, each with its uncertainty.
- Finding cards — status tag (
established,provisional,no effect), one bold claim, the reasoning, an is-versus-target line naming the origin of the target, and a statistics line with effect size,nandp. - Null findings as their own cards. A measured absence of an effect is a result, and the one most often lost. Grey tag, explicit "no action needed". A section that quietly omits what was tested and found flat reads as if it had never been tested.
- Hypotheses — everything at p ≥ 0.05, each with the observable that would decide it.
- Limits — every extrapolation, every soft anchor a conclusion leans on, every capability the source cannot deliver.
Step 5 — offer the state update, do not perform it
The report is a rendering; it is not the database. Findings that should survive
the session go in through update-db.py, and only with the athlete's agreement.
Show the payload, then run the writer. Payload rules are in
/runlab:runlab-methodology.
Rules that decide whether this report is worth anything
- Cite, never compute. Enforced by
--check, but hold to it in your own prose too: a sentence that carries its own number bypasses the mechanism. - One denominator per claim. "Cadence is low" is an opinion; "cadence below target in 9 of 14 easy runs" is a finding.
- A soft anchor contaminates everything downstream. If
computed.anchors.<key>.zone_statement_allowedis false, no sentence may name a zone without saying the boundary is provisional. - The remainder block is reported separately, always.
- Do not smooth the story. If two analyses disagree, print both and say so.