@wattsonclub/wattson-cli 0.1.0 → 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.
@@ -1,507 +1,292 @@
1
- # Workout building blocks
1
+ # Workout building blocks: exact authoring contract
2
2
 
3
- This doc describes the current **atomic workout grammar** in `packages/domain` and how agents should think about composing workouts from it.
4
-
5
- It is intentionally agent-oriented.
6
-
7
- Goal:
8
- - make the workout structure legible without reading raw Zod code
9
- - expose the atomic building blocks that agents can combine
10
- - make validation/revision loops more reliable
11
- - establish a stable conceptual vocabulary for future CLI/MCP/chat interfaces
12
-
13
- Canonical source of truth remains the domain schema.
14
- This doc is a human/agent guide, not a replacement for the schema.
15
-
16
- Source of truth:
17
- - `packages/domain/src/index.ts`
18
-
19
- ---
20
-
21
- ## 1. Mental model
22
-
23
- A workout is:
24
- - metadata + rationale + constraints
25
- - a sequence of **blocks**
26
- - where blocks are either:
27
- - simple timed blocks
28
- - repeated interval sets
29
- - notes/support markers
30
-
31
- The useful abstraction for agents is:
32
-
33
- 1. choose an **objective**
34
- 2. choose a **macro shape**
35
- 3. fill that shape with typed blocks
36
- 4. choose targets/execution modes/cadence/HR constraints
37
- 5. validate
38
- 6. revise until clean
39
-
40
- In other words:
41
- - agents should compose workouts from typed pieces
42
- - agents should not treat the workout as an opaque paragraph
43
-
44
- ---
45
-
46
- ## 2. Top-level workout fields that matter most
47
-
48
- These fields drive authoring behavior most strongly:
49
-
50
- - `title`
51
- - `objective`
52
- - `athleteContext.ftpWatts`
53
- - `blocks[]`
54
- - `constraints`
55
- - `rationale`
56
- - optional `computed`
57
- - optional `validation`
58
- - optional `scheduledDate` (`YYYY-MM-DD`): the day the rider plans to ride it. Set with `schedule_saved_workout`; it orders the library and never starts a ride.
59
-
60
- Important practical point:
61
- - the **blocks** define the actual workout shape
62
- - the **constraints** define what is allowed/safe/preferred
63
- - the **validation/computed** fields define whether the workout is coherent/executable
64
-
65
- ---
66
-
67
- ## 3. Atomic building blocks
68
-
69
- ## 3.1 Simple timed blocks
70
-
71
- These are the main single-step building blocks.
72
-
73
- Supported simple block types:
74
- - `warmup`
75
- - `steady`
76
- - `recovery`
77
- - `cooldown`
78
- - `ramp`
79
- - `free_ride`
80
- - `cadence_drill`
81
- - `rest`
82
-
83
- Shape:
84
- - `id`
85
- - `type`
86
- - `durationSec`
87
- - optional `target`
88
- - optional `title`
89
- - optional `instructions[]`
90
- - optional `cadence`
91
- - optional `hr`
92
-
93
- ### When to use them
94
-
95
- #### `warmup`
96
- Use for:
97
- - gradually preparing the rider
98
- - low-to-moderate progressive openers
99
- - cadence ramping without heavy load
100
-
101
- #### `steady`
102
- Use for:
103
- - endurance
104
- - tempo
105
- - threshold holds
106
- - any stable target period
107
-
108
- #### `recovery`
109
- Use for:
110
- - low-load valleys between work bouts
111
- - aerobic reset periods
112
-
113
- #### `cooldown`
114
- Use for:
115
- - winding the session down
116
- - reducing target and cadence pressure at the end
117
-
118
- #### `ramp`
119
- Use for:
120
- - explicit increasing/decreasing load phases
121
- - progressive openers or build segments
122
-
123
- #### `free_ride`
124
- Use for:
125
- - rider-controlled sections
126
- - open movement / transitions
127
- - less prescriptive sections where target control should be loose
128
-
129
- #### `cadence_drill`
130
- Use for:
131
- - skill emphasis
132
- - cadence-specific work where the pedaling cue matters as much as the power target
133
-
134
- #### `rest`
135
- Use for:
136
- - explicit rests distinct from active recovery
137
- - true low/no-load gaps
138
-
139
- ---
140
-
141
- ## 3.2 Interval set block
142
-
143
- Composite block type:
144
- - `interval_set`
145
-
146
- Shape:
147
- - `id`
148
- - `type: "interval_set"`
149
- - `repeat`
150
- - `steps[]`
151
- - optional `title`
152
- - optional `instructions[]`
153
- - optional `cadence`
154
- - optional `hr`
155
-
156
- This is the main repeating composition primitive.
157
-
158
- Think of it as:
159
- - a small local workout pattern
160
- - repeated `n` times
161
-
162
- That makes it the core tool for:
163
- - VO2 reps
164
- - threshold reps
165
- - over/unders (using step structure)
166
- - cadence skill repeats
167
- - repeated recovery/work structures
168
-
169
- ### Allowed interval-set step types
170
- Inside an `interval_set`, steps can be:
171
- - `work`
172
- - `recovery`
173
- - `steady`
174
- - `ramp`
175
-
176
- This is important:
177
- - the outer block says “repeat this structure”
178
- - the inner steps say what the repeated structure actually is
179
-
180
- ### Good uses
181
-
182
- #### Classic 3x VO2
183
- - interval_set repeat 3
184
- - work 2m @ 115% FTP
185
- - recovery 2m @ 50% FTP
186
-
187
- #### Threshold ladder
188
- - interval_set repeat 2
189
- - steady 6m @ 100% FTP
190
- - recovery 2m @ 55% FTP
191
-
192
- #### Cadence focus set
193
- - interval_set repeat 4
194
- - steady 1m @ 75% FTP cadence 105+
195
- - recovery 1m @ 50% FTP
196
-
197
- ---
198
-
199
- ## 3.3 Climb block
200
-
201
- Composite terrain block:
202
- - `climb`
203
-
204
- Shape:
205
- - `id`
206
- - `type: "climb"`
207
- - `course` (a `CourseProfile`: name, source, totals, and `segments[]` of `{ distanceM, gradePct }`)
208
- - optional `pacingPctFtp` (default `0.85`)
209
- - optional `title`, `instructions[]`, `cadence`, `hr`
210
-
211
- A climb is a simulated ascent: the trainer runs in gradient simulation mode and follows the course's grade
212
- at the rider's virtual position, which the app computes from live watts, rider and bike mass, and the
213
- profile. The block ends when the rider reaches the end of the course, not on a timer. Its compiled
214
- `durationSec` is a physics estimate at `pacingPctFtp × FTP` and drives previews, TSS and zone time only.
215
-
216
- Rules:
217
- - `constraints.allowedExecutionModes` must include `"grade"`.
218
- - Get `course` from the planner's climb catalog (`lookup_climb`), a Strava segment import
219
- (`import_strava_segment`), one of the rider's own Strava rides (`import_strava_activity_course`, source
220
- kind `strava_activity`, optionally cut to a segment effort or distance window), a Strava route
221
- (`import_strava_route`, source kind `strava_route`), a GPX file or public GPX link (`import_gpx_course`,
222
- source kind `gpx`), a myCols or climbfinder page (`import_climb_page`, source kind `climb_page`), the
223
- road between two places (`build_road_course`, source kind `road`, OpenStreetMap
224
- geometry plus raster elevation) or generated terrain
225
- (`generate_terrain_course`, source kind `synthetic`: a distance, average grade and shape, deterministic
226
- for the same inputs). Saved workouts keep a snapshot of the course, so disconnecting Strava does not
227
- remove the climb.
228
- - `athleteContext.weightKg` improves the estimate (75 kg when absent). Bike mass is fixed internally at 7.5 kg; legacy profile/workout bike weights are ignored. W/kg uses rider weight only.
229
- - Trainers without simulation mode ride the climb in ERG at the pacing watts.
230
-
231
- Validation warns when a course is under 300 m, averages under 1 %, or has grades outside −20..25 % (they
232
- are clamped for the trainer).
233
-
234
- Every ride also gets a virtual distance (`actualMetrics.distanceM`, `ascentM`): climbs from the course
235
- odometer, ERG steps as a flat road at the recorded power, written into the FIT file so Strava counts the
236
- mileage. After the ride, `summarize_session` reports each climb the rider started (`summary.climbs`): distance and
237
- ascent actually ridden from the app's virtual odometer, time, average power, VAM and whether the summit was
238
- reached. The app's ride history shows the same figures with the profile.
239
-
240
- ## 3.4 Note block
241
-
242
- Support-only block type:
243
- - `note`
244
-
245
- Shape:
246
- - `id`
247
- - `type: "note"`
248
- - `text`
249
-
250
- Use for:
251
- - non-executable guidance
252
- - rider coaching context
253
- - explicit reminders that should survive revision/rendering
254
-
255
- Agents should use `note` sparingly.
256
- If the content changes the actual executable session shape, it should probably be a real workout block, not just a note.
257
-
258
- ---
259
-
260
- ## 4. Target grammar
261
-
262
- Targets are also typed.
263
- This matters because agents should choose targets intentionally rather than describing them vaguely.
264
-
265
- Supported target modes:
266
- - `open`
267
- - `watts`
268
- - `pct_ftp`
269
- - `zone`
270
- - `ramp_watts`
271
- - `ramp_pct_ftp`
272
- - `resistance_level`
273
- - `grade_percent`
274
-
275
- ## 4.1 Practical meaning
276
-
277
- ### `open`
278
- No strict load target.
279
- Good for:
280
- - free ride
281
- - easy reset
282
- - technique-focused movement
283
-
284
- ### `watts`
285
- Absolute target.
286
- Good when:
287
- - athlete FTP/context is already resolved
288
- - exact prescription matters
289
-
290
- ### `pct_ftp`
291
- Relative target.
292
- Good default for authoring because:
293
- - portable across riders
294
- - easier for agents to reason about conceptually
295
-
296
- Important:
297
- - `%FTP` values are fractional in structured payloads
298
- - use `0.55` for `55% FTP`
299
- - use `1.15` for `115% FTP`
300
- - never pass whole percentages like `55` or `115`
301
-
302
- ### `zone`
303
- Zone-based target.
304
- Good for:
305
- - simple endurance/recovery prescriptions
306
- - more human-facing authoring when exact percentage is unnecessary
307
-
308
- ### `ramp_watts` / `ramp_pct_ftp`
309
- Progressive target from start -> end.
310
- Good for:
311
- - warmups
312
- - build phases
313
- - progressive efforts
314
-
315
- ### `resistance_level`
316
- Trainer resistance mode target.
317
- Use when explicitly designing for resistance execution.
318
-
319
- ### `grade_percent`
320
- Grade-mode target.
321
- Use when explicitly designing for grade/simulation execution.
322
-
323
- ---
324
-
325
- ## 5. Additional per-block constraints/cues
326
-
327
- Blocks and interval steps can also carry:
328
-
329
- ### cadence
330
- Shape:
331
- - `min`
332
- - `max`
333
- - `cue`
334
-
335
- Use for:
336
- - skill emphasis
337
- - spin-up drills
338
- - low-cadence strength cues
339
-
340
- ### hr
341
- Shape:
342
- - `capBpm`
343
- - `softCapBpm`
344
-
345
- Use for:
346
- - safety bounds
347
- - recovery gating
348
- - keeping endurance/recovery blocks honest
349
-
350
- ### instructions[]
351
- Use for:
352
- - rider-facing execution guidance
353
- - short coaching prompts
354
- - reminders that belong to the block itself
355
-
356
- Good examples:
357
- - `Relax shoulders`
358
- - `Stay seated`
359
- - `Smooth pedal stroke`
360
- - `Do not sprint the start`
361
-
362
- ---
363
-
364
- ## 6. Workout-level constraints
365
-
366
- Workout-level constraints currently include:
367
- - `maxDurationSec`
368
- - `maxPctFtp`
369
- - `maxContinuousWorkSec`
370
- - `maxHrBpm`
371
- - `allowedExecutionModes`
372
- - `disallowStanding`
373
- - `avoidSprintStarts`
374
- - `indoorOnly`
375
-
376
- These are important because they tell the agent what *not* to build.
377
-
378
- Important:
379
- - `maxPctFtp` is also fractional
380
- - use `1.2` for `120% FTP`, not `120`
381
-
382
- Practical rule:
383
- - if the user request conflicts with workout constraints, the agent should prefer a legal workout + explanation over inventing an invalid one and hoping validation fixes it later
384
-
385
- ---
386
-
387
- ## 7. Composition patterns agents should use
388
-
389
- These are not hardcoded templates.
390
- They are strong composition patterns.
391
-
392
- ## 7.1 Endurance ride with gentle structure
393
- - warmup
394
- - steady endurance block
395
- - optional cadence drill inserts
396
- - cooldown
397
-
398
- ## 7.2 VO2 session
399
- - warmup
400
- - interval_set(work/recovery x n)
401
- - cooldown
402
-
403
- ## 7.3 Threshold / sweet spot session
404
- - warmup
405
- - longer steady work block(s)
406
- - recovery between blocks if needed
407
- - cooldown
408
-
409
- ## 7.4 Skills session
410
- - warmup
411
- - cadence_drill or free_ride sections with explicit cadence cues
412
- - light steady aerobic work
413
- - cooldown
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`.
414
4
 
415
- ## 7.5 Recovery session
416
- - warmup or very gentle open start
417
- - low-load steady / open blocks
418
- - cadence comfort cues
419
- - cooldown optional if already very easy
5
+ ## Drafting versus composing
420
6
 
421
- ---
7
+ `draft_workout` generates a deterministic starter, not an arbitrary-block authoring language. Input is:
422
8
 
423
- ## 8. What agents should usually do first
9
+ ```json
10
+ {
11
+ "objective": "vo2max",
12
+ "durationMin": 20,
13
+ "constraints": { "maxPctFtp": 1.2, "allowedExecutionModes": ["erg"] },
14
+ "context": { "styleHints": ["three controlled efforts"] }
15
+ }
16
+ ```
424
17
 
425
- When authoring from natural language, agents should generally choose in this order:
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.
426
19
 
427
- 1. infer the workout objective
428
- 2. infer intended duration
429
- 3. choose whether the workout is mostly:
430
- - steady/simple blocks
431
- - repeated intervals
432
- - skills/free ride
433
- 4. choose target style:
434
- - `pct_ftp` is usually the safest authoring default
435
- - `zone` is okay for simpler low-intensity prescriptions
436
- - `watts` is better when exact absolute load matters
437
- 5. add cadence / HR cues only when they actually help
438
- 6. validate before saving
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.
439
21
 
440
- ---
22
+ ## Complete workout envelope
441
23
 
442
- ## 9. How validation should fit into authoring
24
+ Required fields (unless a default is stated):
443
25
 
444
- Current validation structure already includes:
445
- - `status: pass | warn | fail`
446
- - `issues[]`
447
- - each issue has:
448
- - `code`
449
- - `message`
450
- - `path[]`
451
- - `severity: warning | error`
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 `[]`. |
452
41
 
453
- That is already a strong base for agent loops.
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`.
454
43
 
455
- The practical loop should be:
456
- 1. draft from blocks
457
- 2. validate
458
- 3. inspect issue `severity`
459
- 4. use issue `path` to identify which block or step is the problem
460
- 5. revise only the relevant part
461
- 6. validate again
44
+ Actors are exactly one of:
462
45
 
463
- That is much better than “draft once and hope.”
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
+ ```
464
51
 
465
- ---
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.
466
53
 
467
- ## 10. What usually validates/revises well
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.
468
55
 
469
- Agents will usually do better when they:
470
- - use a clear warmup / work / cooldown shape
471
- - use `pct_ftp` rather than over-specific absolute watts too early
472
- - keep repeated sets explicit with `interval_set`
473
- - keep rider coaching in `instructions[]` rather than in long prose
474
- - use workout constraints honestly
475
- - treat `validate` as part of authoring, not as a final optional extra
56
+ ## All block primitives
476
57
 
477
- ---
58
+ Executable blocks share `id` (nonempty), optional `title` string, `instructions` string array default `[]`, optional `cadence` and `hr`.
478
59
 
479
- ## 11. Common mistakes agents should avoid
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. |
480
66
 
481
- ### 1. Treating notes as executable structure
482
- If it affects the ride shape, make it a block.
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.
483
68
 
484
- ### 2. Overusing vague prose instead of typed blocks
485
- The more vague the workout description, the harder it is to revise and validate.
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.
486
70
 
487
- ### 3. Using live adaptation to solve pre-execution authoring problems
488
- If the workout is not yet running, revise the saved workout.
489
- Do not force everything through `session adapt`.
71
+ ## Combining primitives into a session
490
72
 
491
- ### 4. Inventing unsupported runtime changes
492
- Current live ops are bounded.
493
- Structural edits like “add another interval right now” are not first-class runtime ops today.
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.
494
74
 
495
- ### 5. Ignoring constraints and expecting validate to rescue everything
496
- Validation is a loop, not a magic trash compactor.
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. |
497
83
 
498
- ---
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.
499
85
 
500
- ## 12. Recommended next implementation follow-on
86
+ ## All target JSON variants
501
87
 
502
- The next code/doc layers that fit this grammar well are:
503
- - a canonical text notation for workouts
504
- - a structured/chat-friendly workout renderer
505
- - validation feedback that references block/step locations more explicitly in human terms
88
+ Bounds are inclusive unless marked `>`. Numerical values need not be integers unless specified.
506
89
 
507
- Those should sit on top of this block grammar, not replace it.
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.