feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)

Bike-only Zwift .zwo generator with human-readable preview + confirmation loop.

- scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate
- scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates)
- references/zwo-format.md: condensed offline format reference
- assets/example-vo2max.zwo + tests/test_zwo.py (9 tests)
- Publishes via hosting skill; files result into Knowledge Wiki

Format ground truth: h4l/zwift-workout-file-reference
This commit is contained in:
IT Guy
2026-09-20 07:47:05 +00:00
parent 42f37a89f8
commit 109162889c
6 changed files with 993 additions and 0 deletions
@@ -0,0 +1,74 @@
# Zwift .zwo Format Reference (condensed)
Offline copy of the essentials from
<https://github.com/h4l/zwift-workout-file-reference>. A `.zwo` file is XML; there
is **no official Zwift schema**. This is the subset Zwift reliably accepts.
## Document structure
```xml
<?xml version="1.0" encoding="UTF-8"?>
<workout_file>
<author>IT Guy</author>
<name>VO2 Max 5x3</name>
<description>5 × 3 min @ 115 % FTP</description>
<sportType>bike</sportType>
<tags><tag name="vo2max"/></tags>
<workout>
<Warmup Duration="600" PowerLow="0.50" PowerHigh="0.75"/>
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180"
OnPower="1.15" OffPower="0.55"/>
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.40"/>
</workout>
</workout_file>
```
## Conventions
- **Power** is a fraction of FTP (`0.50` = 50 %, `1.15` = 115 %). `units="power"`
is the implicit default.
- **Duration** is always in **seconds**.
- `<sportType>` values seen: `bike` (61 %), `run` (37 %), `swim`, `ride`. We use `bike`.
- Text cues are children of a step: `<textevent timeoffset="30" message="Push!"/>`.
## Step elements (child of `<workout>`)
| Element | Attributes (relevant) | Meaning |
|---|---|---|
| `Warmup` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp Low→High |
| `Cooldown` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp High→Low |
| `SteadyState` | `Duration`, `Power`, (`PowerLow`,`PowerHigh`), `Cadence` | constant target |
| `SolidState` | `Duration`, `Power` | constant power, no ERG variation |
| `Ramp` | `Duration`, `PowerLow`, `PowerHigh` | explicit ramp |
| `IntervalsT` | `Repeat`, `OnDuration`, `OffDuration`, `OnPower`, `OffPower`, `Cadence`, `CadenceResting`, `OverUnder` | N repeated on/off pairs |
| `FreeRide` | `Duration`, `FlatRoad` | ERG off, rider's choice |
| `MaxEffort` | `Duration` | all-out, ERG off |
| `RestDay` | — | placeholder |
| `textevent` | `timeoffset`, `message`, `duration` | on-screen cue |
## Top-level metadata elements (child of `<workout_file>`)
`activitySaveName`, `author`, `author_alias`, `authorIcon`, `category`,
`categoryIndex`, `description`, `durationType`, `entid`, `ftpOverride`,
`ftpMaleOverride`, `ftpFemaleOverride`, `name`, `nameImperial`, `nameMetric`,
`painIndex`, `setFtpAtPercentage`, `ShowCP20`, `Skippable`, `sportType`,
`subcategory`, `tags`, `test_details`, `visibleAfterTime`, `visibleOutsidePlan`,
`workout`, `workoutLength`, `WorkoutPlan`.
**Minimal viable set we emit:** `author`, `name`, `description`, `sportType`,
`tags`, `workout`.
## Importing a .zwo into Zwift
1. Copy the file into `~/Documents/Zwift/Workouts/<userID>/` (folder name is your
numeric Zwift ID), **or**
2. Use the Custom Workouts import on zwift.com, **or**
3. Drop it into the Zwift folder sync (Dropbox/OneDrive) if configured.
Then restart Zwift; the workout appears under **Custom Workouts**.
## Gotchas
- Malformed XML or unescaped `&`/quotes → Zwift silently ignores the file.
- Power outside ~0.20–1.50 FTP is almost always a unit mistake.
- Duplicate/unknown attributes are tolerated but ignored; keep it minimal.