@wattsonclub/wattson-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ # Workout building blocks: exact authoring contract
2
+
3
+ Structured JSON is canonical. The renderer is implemented; no workout-text parser exists. See [workout-text-format.md](workout-text-format.md). Audited against `packages/domain/src/index.ts`, `packages/domain/src/course.ts`, `packages/contracts/src/tools.ts`, and `services/planner-api/src/service.ts`.
4
+
5
+ ## Drafting versus composing
6
+
7
+ `draft_workout` generates a deterministic starter, not an arbitrary-block authoring language. Input is:
8
+
9
+ ```json
10
+ {
11
+ "objective": "vo2max",
12
+ "durationMin": 20,
13
+ "constraints": { "maxPctFtp": 1.2, "allowedExecutionModes": ["erg"] },
14
+ "context": { "styleHints": ["three controlled efforts"] }
15
+ }
16
+ ```
17
+
18
+ Both MCP surfaces register the name `draft_workout`. The internal explicit-athlete surface uses `DraftWorkoutInputSchema` and also requires `athleteId`; the authenticated surface uses `DraftMyWorkoutInputSchema`, omits `athleteId`, and resolves the signed-in athlete. `draftMyWorkout` is a client/service method, not a separate MCP tool name. `durationMin` is a positive integer (no schema maximum; resulting workouts must be at most 24 hours). Only `maxPctFtp` and `allowedExecutionModes` are accepted draft constraints. `context.readinessSnapshotId` is an optional nonempty ID; it is not consulted by this deterministic builder. `styleHints` defaults to `[]` and is copied into rationale, **not interpreted to change blocks**. Mode preference is ERG, then resistance, then grade. Allowing grade does not generate a distance-based climb.
19
+
20
+ For custom composition, take the returned `.workout`, edit its `blocks`, constraints, title and rationale, then pass `{ "workout": ... }` to validate and save. Draft, revise and validate are candidates/read-only with respect to the saved library; **save publishes**. Unknown object properties are generally stripped by Zod, not implemented: putting `blocks`, a custom title, or extra constraints in draft input does not author them.
21
+
22
+ ## Complete workout envelope
23
+
24
+ Required fields (unless a default is stated):
25
+
26
+ | Field | Contract |
27
+ | --- | --- |
28
+ | `id`, `athleteId` | Nonempty strings. Preserve server-returned identity when editing a candidate. |
29
+ | `version` | Positive integer. Fresh draft is 1; revision candidate is base version + 1. |
30
+ | `createdAt`, `updatedAt` | ISO datetime strings. |
31
+ | `createdBy`, `updatedBy` | Actor objects below. |
32
+ | `kind`, `sport` | Exactly `workout_definition`, `cycling_indoor`. |
33
+ | `title` | Nonempty string (workout schema has no maximum). |
34
+ | `objective` | `recovery`, `endurance`, `tempo`, `sweet_spot`, `threshold`, `vo2max`, `anaerobic`, `sprint`, `skills`, `test`, `custom`. |
35
+ | `status` | `draft`, `validated`, `scheduled`, `retired`. Save recalculates status; do not claim validation by changing this field. |
36
+ | `athleteContext` | Required `ftpWatts` > 0; optional `weightKg` > 0 and positive integer `maxHrBpm`; `constraints` string array defaults to `[]`. This context is a snapshot, not a live profile reference. |
37
+ | `blocks` | Nonempty array of blocks below. At least one executable block is needed. |
38
+ | `constraints` | Defaults to the constraint defaults below. |
39
+ | `rationale` | Required `summary` string (can be empty); `whyToday` string array defaults `[]`; optional `adaptationGoal` string, `confidence` in [0,1]. |
40
+ | `tags` | String array, default `[]`. |
41
+
42
+ Optional: `scheduledDate` (`YYYY-MM-DD` regex; use scheduling tools to manage the library day, never to start a ride), `sourceWorkoutId` (nonempty string), `computed`, `validation`, `sharing`, `workoutFingerprint`.
43
+
44
+ Actors are exactly one of:
45
+
46
+ ```json
47
+ {"type":"user","userId":"user-id"}
48
+ {"type":"agent","agentId":"agent-id","runId":"optional-run-id","promptRef":"optional-prompt-id"}
49
+ {"type":"system","system":"validator"}
50
+ ```
51
+
52
+ System choices: `scheduler`, `runtime`, `validator`, `importer`. IDs are nonempty. Retain draft attribution rather than fabricate actors. `sharing` is server-owned lineage: `originalWorkoutId`, `originalWorkoutVersion`, `originalShareId`, `originalFingerprint`, `sourceWorkoutId`, `sourceWorkoutVersion`, `shareId`. Versions are positive integers, share IDs UUIDs, fingerprints match `^v1:[a-f0-9]{64}$`. After input schema parsing, `saveWorkout` overwrites `sharing` with the latest saved record’s lineage, or clears it for a new ID, then computes `workoutFingerprint`; do not synthesize either. This ownership rule concerns `sharing`, not the separate top-level `sourceWorkoutId`. Malformed supplied lineage can still fail input parsing before it is discarded.
53
+
54
+ `computed` has positive integer `durationSec`, nonnegative `intensityFactor`, `tss`, optional nonnegative `estimatedKj`, and `zoneDistributionSec` with nonnegative z1–z5 and optional z6–z7. `validation` has `status: pass|warn|fail`, `issues` default `[]`; each issue has string `code`, `message`, string-array `path` default `[]`, and `severity: warning|error`. Remove stale computed/validation after edits; validation/save recompute them.
55
+
56
+ ## All block primitives
57
+
58
+ Executable blocks share `id` (nonempty), optional `title` string, `instructions` string array default `[]`, optional `cadence` and `hr`.
59
+
60
+ | Type | Required additional fields | Meaning |
61
+ | --- | --- | --- |
62
+ | `warmup`, `steady`, `recovery`, `cooldown`, `ramp`, `free_ride`, `cadence_drill`, `rest` | `durationSec`: positive integer; optional `target` | One timed step. Type alone does **not** set power, ramp, or trainer mode. `rest` is not automatically zero watts; `free_ride` with a power target is ERG. |
63
+ | `interval_set` | `repeat`: positive integer; `steps`: nonempty array | Repeat the complete step sequence. No outer duration or target. |
64
+ | `climb` | `course`: full course object; optional `pacingPctFtp` | Distance-ended simulation; no authored `durationSec` or `target`. |
65
+ | `note` | `id`, `type`, `text`: nonempty string | No duration, target, instructions, cadence, HR or title. Not executable. |
66
+
67
+ Interval steps use the shared fields plus positive integer `durationSec` and optional `target`, but **only** `work`, `recovery`, `steady`, `ramp` types. No nested sets, climbs, notes, warmups, cooldowns, cadence-drill or rest step types. `work` is not a top-level simple type. Use a `steady` interval step with cadence cues for repeated skills work. There is no schema maximum repeat/step count; keep expansions reasonable and total duration <= 86,400 seconds.
68
+
69
+ Interval compilation expands every repetition. Runtime IDs are `<set-id>:r1:s1` etc., not the step's authored ID. Parent and step instructions merge with duplicate strings removed. Cadence minimum/cue use child value when present, otherwise parent; cadence maximum and both HR caps use the **lower** parent/child value. Parent set title does not become each step title. Notes disappear from execution.
70
+
71
+ ## Combining primitives into a session
72
+
73
+ `blocks` execute in array order; there is no parallel execution or conditional branching. Each `interval_set` expands its whole sequence for every repetition before the next top-level block begins. Recovery at the end of a set repeats too, including after the final effort.
74
+
75
+ | Recipe | Composition and timing |
76
+ | --- | --- |
77
+ | VO2 repetitions | Warmup → set of work + recovery → cooldown. 300 + 3 × (120 + 120) + 180 = 1,200 seconds. See the complete example below. |
78
+ | Over/unders | Warmup → set with two `work` steps at different FTP fractions → recovery → cooldown. Both efforts count as continuous work when each is >= .88 FTP; an “under” is not automatically recovery. |
79
+ | Ladder | Differently timed work/recovery steps in a set with `repeat: 1`, or multiple sets in `blocks`. Do not nest sets. |
80
+ | Two sets with a long break | First set → top-level recovery → second set. Put the long recovery outside the sets so it is not repeated. |
81
+ | ERG + terrain | Power warmup → climb with a returned full `course` → power cooldown. Allow both `erg` and `grade`; total duration includes a climb estimate, not a fixed finish time. |
82
+ | Repeated cadence skills | Set of `steady` steps with cadence and instructions, not `cadence_drill` children. Child cadence/HR inheritance follows the rules above. |
83
+
84
+ Choose targets separately from block labels: a `ramp` needs a ramp target to ramp power; a `recovery` label does not supply recovery watts. Include all non-open modes used in `allowedExecutionModes`. Calculate duration limits for the whole composition, not one repetition. Prefer distinct block IDs and retain the workout envelope; combine authored blocks, not saved workout envelopes or runtime plans.
85
+
86
+ ## All target JSON variants
87
+
88
+ Bounds are inclusive unless marked `>`. Numerical values need not be integers unless specified.
89
+
90
+ | Exact object example | Schema limits | Runtime |
91
+ | --- | --- | --- |
92
+ | `{"mode":"open"}` | No value | Open |
93
+ | `{"mode":"watts","value":200}` | value > 0, no schema upper bound | ERG watts |
94
+ | `{"mode":"pct_ftp","value":0.88}` | 0 < value <= 3 | ERG, rounded fraction × FTP |
95
+ | `{"mode":"zone","value":"z2"}` | z1, z2, z3, z4, z5, z6, z7 | ERG using fixed fractions below |
96
+ | `{"mode":"ramp_watts","start":100,"end":180}` | Both > 0 | ERG start/end watts |
97
+ | `{"mode":"ramp_pct_ftp","start":0.55,"end":0.7}` | Each > 0 and <= 3 | ERG start/end, each rounded fraction × FTP |
98
+ | `{"mode":"resistance_level","value":35}` | 0..100 | Resistance |
99
+ | `{"mode":"grade_percent","value":4}` | −20..25 | Timed constant grade; not a distance-ended climb |
100
+
101
+ Missing target executes open. No implicit default intensity is assigned. Fractions are **0.55 for 55%**, never 55. Zero watts / zero FTP fraction is rejected; use open or a supported resistance target, not an invented zero-watt target. Ramps may ascend, descend or stay equal, and any simple block/interval step may carry any target variant.
102
+
103
+ Zone targets do not use the athlete's configured zone ranges: fixed fractions are z1=.50, z2=.68, z3=.83, z4=.98, z5=1.13, z6=1.35, z7=1.65. Ramps use their endpoint **average** for intensity validation and metrics, not endpoint peak. Check endpoints yourself when enforcing a strict peak ceiling.
104
+
105
+ ## Cadence, HR and workout constraints
106
+
107
+ `cadence` is `{ "min": 85, "max": 100, "cue": "Smooth pedalling" }`; all fields optional, min/max positive integers. No min<=max check exists. `hr` is `{ "capBpm": 165, "softCapBpm": 155 }`; both optional positive integers. Empty cadence/HR objects are schema-valid. These are not conditional branching/recovery-gate instructions.
108
+
109
+ Compiler emits cadence **maximum** and the lower hard/soft HR cap as step safety bounds; cadence minimum/cue are not runtime target fields. Put essential rider cues in `instructions` too. Workout max HR / max FTP produce default safety bounds.
110
+
111
+ | Constraint | Range / default |
112
+ | --- | --- |
113
+ | `maxDurationSec` | Optional positive integer; global 24-hour ceiling also applies. |
114
+ | `maxPctFtp` | Optional fraction > 0 and <= 3. |
115
+ | `maxContinuousWorkSec` | Optional positive integer. |
116
+ | `maxHrBpm` | Optional positive integer. Athlete context max HR alone does not set this constraint. |
117
+ | `allowedExecutionModes` | Array of `erg`, `resistance`, `grade`; default `["erg"]`. Empty allowed by schema. Open execution is always permitted; `open` is not an allowed-array enum value. |
118
+ | `disallowStanding` | Boolean default false. |
119
+ | `avoidSprintStarts` | Boolean default false. |
120
+ | `indoorOnly` | Boolean default true; sport is still indoor cycling when false. |
121
+
122
+ ## Climb course authoring and attribution
123
+
124
+ Prefer a full `course` returned by catalog lookup/import/generation over manually inventing geography. Supported source kinds: `catalog`, `strava_segment`, `strava_route`, `strava_activity`, `gpx`, `road`, `climb_page`, `synthetic`, `manual`. Preserve source provenance and attribution. Workouts store a snapshot, not a dependency on a connected source account.
125
+
126
+ A `course` requires:
127
+
128
+ - `id`, `name`: nonempty strings.
129
+ - `source`: required `kind` enum above; optional string `externalId`, valid URL `url`, string `attribution`, ISO datetime `importedAt`.
130
+ - `totalDistanceM` > 0; `totalAscentM` >= 0; `totalDescentM` >= 0 defaults 0.
131
+ - `averageGradePct`, `maxGradePct`: numbers.
132
+ - `segments`: nonempty array of `{ "distanceM": positiveNumber, "gradePct": numberFromMinus30To35 }`.
133
+ - Optional `region` string, numeric `startElevationM`, `summitElevationM`, `climbCategory`: `hc|1|2|3|4|uncategorized`.
134
+ - Optional `track`: 2..300 points with `distanceM` >= 0, latitude −90..90, longitude −180..180. Keep distances ordered and totals consistent; schema does not cross-check aggregate totals against segments. `buildCourseProfile` computes totals/category and downsamples tracks locally.
135
+
136
+ `pacingPctFtp` is > 0 and <= 3, resolved default **0.85** (not inserted as a schema default). Compiled pacing watts = rounded FTP × fraction. Estimate uses rider mass from `athleteContext.weightKg` or 75 kg, fixed bike mass 7.5 kg, and elevation when available. Legacy bike weights are ignored.
137
+
138
+ Include `grade` in allowed modes (and `erg` for power-target warmup/cooldown). Compiler emits grade mode, clamped mean-grade initial target, segment grades clamped to −20..25%, and a course runtime payload carrying attribution/pacing/physics. The runtime tracks live virtual distance and finishes at the course end, **not** the estimated duration. Unsupported simulation hardware can fall back to pacing-watts ERG in the app; the compiler still requires grade authorization. Changing pacing changes estimated duration/TSS, not the terrain or a guaranteed completion time. Duration constraints on climbs are therefore preview estimates, not time guarantees.
139
+
140
+ Warnings: total distance < 300 m (`climb.course.too_short`), mean grade < 1% (`climb.course.flat`), any segment outside trainer −20..25% (`climb.course.grade_clamped`). Source segment schema still rejects outside −30..35%.
141
+
142
+ ## Validation is necessary, not a complete coaching/safety proof
143
+
144
+ Call `validate_workout` with `{ "workout": candidate }`. It returns `validation`, `computed`, and (unless validation fails) `runtimePlanPreview`. Schema errors happen before semantic validation. A fail blocks artifact runtime compilation; calling the low-level compiler alone is not validation.
145
+
146
+ Implemented semantic checks:
147
+
148
+ - No executable steps; global 24-hour / authored max duration exceedance.
149
+ - Intensity above max FTP, based on constant/zone/ramp-mean/climb-pacing intensity.
150
+ - Non-open execution mode absent from allowed modes.
151
+ - Lower of step hard/soft HR caps greater than workout max HR.
152
+ - Standing text detected with `disallowStanding`: regex `\bstand(?:ing)?\b` in executable title/cadence cue/instructions. Even “do not stand” matches; use “stay seated”. Note text is not checked.
153
+ - Missing target warnings only for `steady`, `ramp`, `work`. Explicit `open` does not warn.
154
+ - Continuous work adds consecutive known-intensity steps >= .88 FTP except `recovery`, `cooldown`, `rest`; other steps reset it. This is not simply the duration of an interval set.
155
+ - With `avoidSprintStarts`, known intensity >= 1.2 starting before elapsed 120 seconds is an error except warmup/recovery/cooldown. First known intensity >= 1.2 also warns, even when preceded by open steps.
156
+ - Climb warnings above. No mandatory warmup/cooldown/cadence-drill cue or objective-specific physiology checks.
157
+
158
+ Issue paths use zero-based string indices, e.g. `["blocks","1","steps","0","repeat","2"]`. `describeWorkoutPath` renders “block 2 > step 1 > repeat 3”; do not interpret indices as text line numbers.
159
+
160
+ Metrics: duration sums flattened steps; ramps count wholly in their average-intensity zone, not split over zones. Unknown effort (open/resistance/constant grade) contributes zero modeled power/IF/TSS and z1 time, not evidence of zero actual effort. Climbs use pacing. IF is root-mean-square duration-weighted FTP ratio (rounded 3 decimals), TSS = hours × IF² × 100 (1 decimal); estimated kJ uses duration-weighted mean power. Zone boundaries are <.56, <.76, <.91, <1.06, <1.21, <1.51, then z7.
161
+
162
+ Save recalculates artifacts; `expectedValidationStatus` (`pass|warn|fail`) is an equality guard, not an override. Failing workouts can be saved as `draft` (except >24 hours, rejected); passing/warning workouts save as `validated`, unless retired. Thus require the validated status you reviewed, rather than assuming save rejects all errors. Notes-only candidates also have zero computed duration, which conflicts with the positive-duration output schema: keep an executable step.
163
+
164
+ ## Edit → revalidate → save loop
165
+
166
+ 1. Keep the complete candidate as `{ "workout": ... }` in `candidate.json`. Edit this definition, not the compiled runtime preview. Unsaved drafts can be edited directly; a saved workout needs a new version (see revisions below).
167
+ 2. Remove stale `computed` and `validation` fields after edits. Keep athlete context and constraints intentional: FTP, targets, repetition counts, courses and pacing can all affect validation and load estimates.
168
+ 3. Run `wattson --json workout validate --input-file candidate.json > validation.json` (MCP: `validate_workout` with the same object). A successful request/exit code is not a validation pass.
169
+ 4. Inspect `validation.status` and every issue's `code`, `path`, `severity` and `message`. Fix schema errors first; locate semantic failures in the authored block/step using the issue path. Repair the actual mismatch, not its status label. Do not loosen a rider's safety constraint simply to obtain a pass.
170
+ 5. Repeat validation after **every** edit: the previous result no longer describes the edited candidate. Check `computed.durationSec`, load estimates and `runtimePlanPreview` for the intended order, repetitions, modes and targets. Review warnings; repair failures before treating the workout as rideable.
171
+ 6. Construct save input from the exact candidate just reviewed, with `expectedValidationStatus` equal to the reviewed status (`pass` by default). Validation returns artifacts, not a replacement workout. The guard compares status only; it does not bind a previous validation response to the candidate. Save recomputes validation.
172
+ 7. For saved-workout edits include `expectedLatestVersion` for the saved base. On conflict, fetch the latest workout, reapply intended edits and validate again; do not blindly change the guard and retry. Fetch the returned saved ID/version and runtime plan to verify persistence.
173
+
174
+ The [command reference](commands.md#revise-a-saved-version) has a guarded revision sequence; the composition example below supplies a complete new-workout sequence. Neither validation nor revision updates an in-progress ride.
175
+
176
+ ## Revisions: supported patch and actual limitations
177
+
178
+ `revise_workout` takes `{ "patch": ... }`, not a full workout. Both MCP surfaces register this same name and use `ReviseWorkoutInputSchema`; neither takes `athleteId`. The authenticated surface calls the client/service method `reviseMyWorkout` and checks ownership of the saved base before revision. It loads a **saved** base; use direct JSON edits for an unsaved draft.
179
+
180
+ ```json
181
+ {
182
+ "patch": {
183
+ "workoutId": "saved-id",
184
+ "baseVersion": 1,
185
+ "preserve": ["objective", "interval_structure"],
186
+ "changes": { "newTitle": "Controlled VO2", "intensityMultiplier": 0.95 },
187
+ "naturalLanguage": "Make the efforts a little easier",
188
+ "reason": "Reduce today's load"
189
+ }
190
+ }
191
+ ```
192
+
193
+ Required: nonempty `workoutId`, positive integer `baseVersion`, string `reason`. `naturalLanguage` is optional descriptive text, **not parsed into operations**. `preserve` defaults `[]`; `changes` defaults `{}`. All change fields are optional:
194
+
195
+ | Change | Contract and actual implementation |
196
+ | --- | --- |
197
+ | `newTitle` | Trimmed nonempty string, max 100 chars. |
198
+ | `newObjective` | Objective enum; label only, does not rebuild structure. |
199
+ | `durationDeltaSec` | Integer; implementation clamps to −600..600, modifies from tail. Negative edits leave changed steps >=30s. Positive edits add to last simple block or last step of last set; repeats multiply the real total change. The helper does not skip notes/climbs: a trailing one has no `durationSec`, so positive edits assign `NaN`; negative edits also poison the remaining subtraction with `NaN`, preventing earlier timed blocks from being shortened. Hand-edit those compositions instead. |
200
+ | `intensityMultiplier` | Schema .7..1.2, implementation clamps to **.8..1.1**. Scales watts/fractions/resistance and ramp endpoints. Zone/open/constant-grade targets unchanged. Climbs scale pacing (clamped .2..3), changing estimated duration too. Other scaled values can exceed target schema limits and make the revision output fail schema parsing (e.g. resistance 100 × 1.1). |
201
+ | `addCadenceFocus` | Boolean; adds min85/max105/cue to non-note top-level blocks without any cadence object, including sets/climbs. Does not replace existing cadence or directly edit child steps. |
202
+ | `removeLastInterval` | Boolean; despite name, current code removes the **last non-interval-set top-level block**, often cooldown/note/climb. It does not remove a repetition. Hand-edit blocks for a real interval deletion. |
203
+ | `switchToRecovery` | Boolean; only rewrites top-level `recovery` targets. Absent target becomes .55 FTP; numeric `value` becomes .55 **in its existing mode** (including watts/resistance/grade). Ramps/zones/open and interval children unchanged; objective unchanged. Not a general recovery transformation. |
204
+
205
+ Preserve semantics:
206
+
207
+ - `objective` suppresses `newObjective` only.
208
+ - `duration` suppresses `durationDeltaSec` only; removal/climb-pacing edits can still change total time.
209
+ - `interval_structure` suppresses `removeLastInterval` and `addCadenceFocus`, not intensity/duration edits.
210
+ - `tss` is accepted and echoed but **not enforced**. Do not promise TSS preservation.
211
+
212
+ Operation order: removal, duration, intensity, cadence, recovery shift, title/objective. A duration change moves `maxDurationSec` only if old cap equalled old computed duration; independent caps remain. Returned diff includes requested intent (not necessarily applied operation), actual final duration and preserve flags. Review candidate blocks and validation, not only diff intent. Revision increments version and timestamp but does not save; save the returned workout with `expectedLatestVersion` set to the base/latest saved version. Direct manual revisions similarly preserve ID, increment version and use the concurrency guard. Retain creation metadata and provide truthful updated attribution.
213
+
214
+ Implementation evidence: MCP names/schemas are in `services/planner-mcp/src/tools.ts` (`registerWriteTools`, `registerPublicWriteTools`). Revision behavior is in `services/planner-api/src/service.ts` (`withRecoveryShift`, `scaleTargetValue`, `withRemovedLastInterval`, `withDurationDelta`, `reviseWorkout`); lineage overwrite is in `saveWorkout`. These are current implementation quirks, not desired coaching semantics.
215
+
216
+ ## Full composition example: draft → edit → validate → save
217
+
218
+ This builds an exact 20-minute session: 5-minute warmup, three 2-minute efforts each followed by 2-minute recovery, 3-minute cooldown. Drafting supplies the real athlete/metadata envelope; the script replaces every structural field it needs, without inventing server IDs or computed values. CLI examples assume an authenticated `wattson`; see [commands.md](commands.md) for installation/authentication.
219
+
220
+ ```sh
221
+ wattson workout draft --objective vo2max --duration-min 20 --json > draft.json
222
+ ```
223
+
224
+ Save this as `compose-workout.mjs` and run `node compose-workout.mjs`:
225
+
226
+ ```js
227
+ import { readFileSync, writeFileSync } from 'node:fs';
228
+ const { workout } = JSON.parse(readFileSync('draft.json', 'utf8'));
229
+ if (!workout) throw new Error('Expected draft response with workout');
230
+ workout.title = 'Controlled VO2 20';
231
+ workout.objective = 'vo2max';
232
+ workout.blocks = [
233
+ { id: 'warmup', type: 'warmup', durationSec: 300,
234
+ target: { mode: 'ramp_pct_ftp', start: 0.55, end: 0.7 },
235
+ instructions: ['Build smoothly'] },
236
+ { id: 'efforts', type: 'interval_set', repeat: 3,
237
+ cadence: { min: 85, max: 105 }, instructions: ['Stay seated'],
238
+ steps: [
239
+ { id: 'work', type: 'work', durationSec: 120,
240
+ target: { mode: 'pct_ftp', value: 1.15 },
241
+ instructions: ['Do not sprint the start'] },
242
+ { id: 'recover', type: 'recovery', durationSec: 120,
243
+ target: { mode: 'pct_ftp', value: 0.5 }, instructions: [] }
244
+ ] },
245
+ { id: 'cooldown', type: 'cooldown', durationSec: 180,
246
+ target: { mode: 'ramp_pct_ftp', start: 0.55, end: 0.45 },
247
+ instructions: ['Ease down gradually'] }
248
+ ];
249
+ workout.constraints = {
250
+ ...workout.constraints,
251
+ maxDurationSec: 1200, maxPctFtp: 1.2, maxContinuousWorkSec: 120,
252
+ allowedExecutionModes: ['erg'], disallowStanding: true,
253
+ avoidSprintStarts: true, indoorOnly: true
254
+ };
255
+ workout.rationale = {
256
+ summary: 'Three controlled two-minute VO2 efforts with equal recovery.',
257
+ whyToday: ['Explicit 20-minute composition; confirm suitability for the rider.'],
258
+ adaptationGoal: 'Sustained high-aerobic efforts with controlled starts'
259
+ };
260
+ workout.tags = ['vo2max', '20min', 'mode:erg'];
261
+ delete workout.computed;
262
+ delete workout.validation;
263
+ writeFileSync('candidate.json', JSON.stringify({ workout }, null, 2));
264
+ ```
265
+
266
+ ```sh
267
+ wattson workout validate --input-file candidate.json --json > validation.json
268
+ ```
269
+
270
+ Inspect all issues and runtime preview, then prepare a guarded save only for a pass:
271
+
272
+ ```js
273
+ // Run as a separate .mjs file after validation.
274
+ import { readFileSync, writeFileSync } from 'node:fs';
275
+ const candidate = JSON.parse(readFileSync('candidate.json', 'utf8'));
276
+ const result = JSON.parse(readFileSync('validation.json', 'utf8'));
277
+ if (result.validation.status !== 'pass') {
278
+ throw new Error(JSON.stringify(result.validation));
279
+ }
280
+ if (result.computed.durationSec !== 1200 || result.runtimePlanPreview.steps.length !== 8) {
281
+ throw new Error('Unexpected compilation');
282
+ }
283
+ writeFileSync('save.json', JSON.stringify({
284
+ workout: candidate.workout, expectedValidationStatus: 'pass'
285
+ }, null, 2));
286
+ ```
287
+
288
+ ```sh
289
+ wattson workout save --input-file save.json --json
290
+ ```
291
+
292
+ This final command persists to the selected account; validation does not. For a revision, add `expectedLatestVersion` to save input. Never save merely to test documentation. The example was checked locally against the schema/compiler and an isolated in-memory planner service, without production writes.
@@ -0,0 +1,122 @@
1
+ # Workout text output (implemented renderer, not input syntax)
2
+
3
+ `packages/domain/src/index.ts` implements:
4
+
5
+ ```ts
6
+ renderWorkoutText(workout, { mode: 'canonical' | 'compact' | 'chat' })
7
+ describeWorkoutPath(path)
8
+ renderValidationIssues(validation)
9
+ ```
10
+
11
+ The default mode is `compact`. **No reverse parser exists.** None of these renderings is lossless, even the one named `canonical`. Structured `WorkoutDefinition` JSON remains canonical for editing, storage, validation and compilation. Do not send text notation to draft/validate/save as if it were a workout payload. See [workout-building-blocks.md](workout-building-blocks.md) for exact fields and a complete draft-edit-validate-save workflow.
12
+
13
+ ## Canonical mode
14
+
15
+ This is an implemented review view, not a proposed future format. For the structured example in the building-blocks reference, the exact renderer output is:
16
+
17
+ ```text
18
+ Workout: Controlled VO2 20
19
+ Objective: vo2max
20
+ Sport: cycling_indoor
21
+ Execution: erg
22
+ Constraints: indoorOnly, maxPctFtp=1.2, maxDuration=20:00, maxContinuousWork=2:00
23
+ Rationale: Three controlled two-minute VO2 efforts with equal recovery.
24
+
25
+ 1. warmup 5:00 target=ramp_pct_ftp(0.55->0.7) instructions=["Build smoothly"]
26
+ 2. interval_set x3 cadence=85-105 instructions=["Stay seated"]
27
+ 2.1 work 2:00 target=pct_ftp(1.15) instructions=["Do not sprint the start"]
28
+ 2.2 recovery 2:00 target=pct_ftp(0.5)
29
+ 3. cooldown 3:00 target=ramp_pct_ftp(0.55->0.45) instructions=["Ease down gradually"]
30
+ ```
31
+
32
+ Header behavior:
33
+
34
+ - Always emits `Workout`, `Objective`, `Sport`, `Execution`, `Rationale` (summary only).
35
+ - `Execution` lists allowed modes, comma-separated; it is **not** a per-step mode declaration. An empty allowed list displays `erg` as a renderer fallback, although semantic validation still disallows non-open steps.
36
+ - `Constraints` emits indoorOnly when true, maxPctFtp, maxDuration, maxContinuousWork, maxHrBpm when present, in that order. It omits `disallowStanding` and `avoidSprintStarts`.
37
+
38
+ Blocks are numbered from 1, including notes. Interval children show `block.step`, once per pattern (not once per repetition). Simple blocks and interval sets show optional title as JSON-quoted `title=...`; interval **step titles are omitted**. Target is absent if not authored. Optional cadence, HR and nonempty instructions follow. Instruction arrays use JSON quoting. Notes render `N. note "quoted text"` and do not execute.
39
+
40
+ Climb output example:
41
+
42
+ ```text
43
+ 2. climb course="Practice hill" distance=2km ascent=100m avg=5% pacing=0.85
44
+ ```
45
+
46
+ A climb can additionally show title, cadence, HR, instructions. Pacing is a **fraction**, default .85. It does not print a fake timed duration; only chat mode shows the estimated duration. Course segments, geometry, source/attribution and most other course metadata are omitted.
47
+
48
+ ## Compact mode
49
+
50
+ Exact compact output for the same example:
51
+
52
+ ```text
53
+ Controlled VO2 20 — vo2max
54
+ WU 5m @55->70% FTP
55
+ 3x [2m @115% FTP / 2m @50% FTP]
56
+ CD 3m @55->45% FTP
57
+ ```
58
+
59
+ Simple labels: `WU`, `STEADY`, `REC`, `CD`, `RAMP`, `OPEN` (free_ride), `CAD`, `REST`. Whole-minute durations use `Nm`; other durations use clock notation. Simple blocks can append `(cad 85-105, hr<=165)` hints. Instructions and titles are omitted. Interval output includes only count, step durations and targets—not step types, cadence, HR, titles, parent cues or instructions. Notes render `NOTE text`.
60
+
61
+ Climb compact form is `CLIMB <name> <distance> <ascent>m avg <grade>% @<pacing-percent>% FTP est`; `est` marks the pacing assumption, not an exact ride time. It omits cadence/HR/instructions. Compact previews are useful for conversation, not safety review or preserving metadata.
62
+
63
+ ## Chat mode
64
+
65
+ Chat mode flattens repetitions and omits notes, using sequential executable-step ordinals. Its numbering is **not** canonical block numbering. Example (two spaces separate fields):
66
+
67
+ ```text
68
+ Controlled VO2 20 — vo2max
69
+ 1. Warmup 5:00 55->70% FTP
70
+ 2. Work 2:00 115% FTP cad 85-105
71
+ 3. Recovery 2:00 50% FTP cad 85-105
72
+ 4. Work 2:00 115% FTP cad 85-105
73
+ 5. Recovery 2:00 50% FTP cad 85-105
74
+ 6. Work 2:00 115% FTP cad 85-105
75
+ 7. Recovery 2:00 50% FTP cad 85-105
76
+ 8. Cooldown 3:00 55->45% FTP
77
+ ```
78
+
79
+ Cadence/HR reflect merged set/step values. Chat output omits instructions and titles, and uses source type labels (`Cadence Drill`, `Free Ride`, etc.). Climb fields are `Climb`, course name/distance/ascent, `~M:SS` estimated time, `avg grade N%`, then cadence/HR when present. The rider ends a climb by completing its virtual distance, not by waiting for that estimate.
80
+
81
+ ## Exact target notation
82
+
83
+ | Structured target | Canonical fragment | Compact/chat human target |
84
+ | --- | --- | --- |
85
+ | `{"mode":"open"}` | `target=open` | `open` |
86
+ | `{"mode":"watts","value":200}` | `target=watts(200)` | `200W` |
87
+ | `{"mode":"pct_ftp","value":0.88}` | `target=pct_ftp(0.88)` | `88% FTP` |
88
+ | `{"mode":"zone","value":"z2"}` | `target=zone(z2)` | `Z2` |
89
+ | `{"mode":"ramp_watts","start":100,"end":180}` | `target=ramp_watts(100->180)` | `100->180W` |
90
+ | `{"mode":"ramp_pct_ftp","start":0.55,"end":0.7}` | `target=ramp_pct_ftp(0.55->0.7)` | `55->70% FTP` |
91
+ | `{"mode":"resistance_level","value":35}` | `target=resistance_level(35)` | `resistance 35` |
92
+ | `{"mode":"grade_percent","value":4}` | `target=grade_percent(4)` | `grade 4%` |
93
+
94
+ Compact prefixes target text with `@`; chat does not. Missing target prints no fragment; explicit open prints `open`. Values use fractions in JSON, not whole percentages. Formatting rounds general numeric values to at most two decimal places and distances to one decimal km; it is not an exact numeric serialization. Clock durations use M:SS below one hour and H:MM:SS above it.
95
+
96
+ Cadence rendering selects `min-max`, `min+`, `<=max`, then cue text, then literal `cue` for an empty object. When numerical bounds exist, cue text is not shown. HR rendering chooses `hr<=capBpm` when present, otherwise `hrSoft<=softCapBpm`; **it does not display both**, whereas compilation uses the lower of both as a safety bound. Review structured HR before execution.
97
+
98
+ ## Validation references
99
+
100
+ `describeWorkoutPath(["blocks","1","steps","0","repeat","2","hr"])` returns:
101
+
102
+ ```text
103
+ block 2 > step 1 > repeat 3 > hr
104
+ ```
105
+
106
+ Indices in issue paths are zero-based strings; display ordinals are one-based. Empty path renders `workout`. Non-block paths become dot-separated (`constraints.maxDurationSec`). `renderValidationIssues` returns an array of lines:
107
+
108
+ ```text
109
+ - error [block 2 > step 1 > repeat 3] target.intensity.exceeds_max_pct_ftp: Target intensity 130% FTP exceeds maxPctFtp 120%.
110
+ ```
111
+
112
+ Human-friendly references already exist; line-stable references and a general revision-diff renderer/parser are not implemented. Use actual issue code/message/path, not invented coaching warnings (there is no general “cooldown too short” or “cadence drill underspecified” check).
113
+
114
+ ## Review workflow
115
+
116
+ 1. Keep structured candidate JSON, including full metadata, constraints and course attribution.
117
+ 2. Render canonical for block review; compact for a short user preview; chat for expanded execution order.
118
+ 3. Validate the structured candidate and inspect computed duration plus runtime plan. Renderer functions do not validate or recompute stale artifacts.
119
+ 4. Make edits in structured JSON or supported revision operations; render again if useful.
120
+ 5. Save the reviewed JSON with validation/version guards. Text previews are never a substitute for the saved definition.
121
+
122
+ Do not round-trip through any text view: all omit IDs, version/actor timestamps, tags, athlete context and important details. There is no implemented plain-text input grammar, no automatic text-to-workout import, and no promise of lossless text diffs.