@trainheroic-unofficial/athlete-mcp 1.5.0 → 1.6.0

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.
Files changed (2) hide show
  1. package/dist/server.mjs +252 -16
  2. package/package.json +3 -3
package/dist/server.mjs CHANGED
@@ -214,6 +214,16 @@ const logSessionArgsSchema = z.object({
214
214
  })).min(1)
215
215
  });
216
216
  logSessionArgsSchema.extend({ athleteId: idArgSchema });
217
+ /**
218
+ * Args for removing a personal (athlete-created) workout session. `programWorkoutId` is the
219
+ * range item's top-level `id`; `date` is that session's day, used to look the item back up so the
220
+ * write can confirm it is a personal session (`personal_cal === true`) before deleting. A
221
+ * coach-scheduled workout is never removed this way.
222
+ */
223
+ const athleteSessionRemoveArgsSchema = z.object({
224
+ programWorkoutId: idArgSchema,
225
+ date: dateString
226
+ });
217
227
  z.looseObject({
218
228
  title: z.string().min(1),
219
229
  param_1_type: z.number().optional(),
@@ -661,6 +671,22 @@ function str(v) {
661
671
  return typeof v === "string" && v !== "" ? v : null;
662
672
  }
663
673
  /**
674
+ * The display title of a saved-copy exercise row, preferring `exercise_title`, then `title`, then
675
+ * the given fallback. The saved-workout shape carries the title under either key depending on the
676
+ * endpoint, so this is the one place that reconciles them.
677
+ */
678
+ function exerciseTitle(ex, fallback = "") {
679
+ return typeof ex.exercise_title === "string" && ex.exercise_title || typeof ex.title === "string" && ex.title || fallback;
680
+ }
681
+ /**
682
+ * True when a program-workout range item is a personal (athlete-created) session rather than a
683
+ * coach-scheduled one. The API marks these with `personal_cal === true`; this is the single
684
+ * definition every consumer (presenter, find-or-create, the remove guard) reads through.
685
+ */
686
+ function isPersonalSession(pw) {
687
+ return isRecord(pw) && pw.personal_cal === true;
688
+ }
689
+ /**
664
690
  * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
665
691
  * exact title, then prefix, then count of matched tokens, with shorter titles and
666
692
  * standard (non-custom) exercises preferred on ties.
@@ -900,6 +926,7 @@ function presentAthleteWorkout(raw) {
900
926
  team: str(rec.team_title),
901
927
  instruction: str(workout.instruction),
902
928
  logged: blocks.some((b) => b.exercises.some((e) => e.performed.length > 0)),
929
+ personal: isPersonalSession(rec),
903
930
  blocks
904
931
  };
905
932
  }
@@ -907,6 +934,81 @@ function presentAthleteWorkouts(list) {
907
934
  return list.map(presentAthleteWorkout);
908
935
  }
909
936
  /**
937
+ * Narrow a coach-athlete workout range to a single program or team. The range endpoint returns
938
+ * every program the athlete is enrolled in on a date; for a high-enrollment athlete that is many
939
+ * workouts, so a caller wanting one program's session narrows it here. Match by `programId`/`teamId`
940
+ * (exact, on the raw `program_id`/`team_id`) or by `programTitle` (case-insensitive substring on the
941
+ * `program_title`/`team_title`) — the title match lets a caller target a program by name without
942
+ * first resolving its id. Any one match keeps the workout; with no filter the list is unchanged.
943
+ */
944
+ function selectWorkoutsByProgram(list, filter) {
945
+ const { programId, teamId, programTitle } = filter;
946
+ const needle = programTitle?.trim().toLowerCase();
947
+ if (programId === void 0 && teamId === void 0 && !needle) return [...list];
948
+ return list.filter((pw) => {
949
+ const rec = pw;
950
+ if (programId !== void 0 && coerceInt(rec.program_id) === programId) return true;
951
+ if (teamId !== void 0 && coerceInt(rec.team_id) === teamId) return true;
952
+ if (needle) {
953
+ if (`${str(rec.program_title) ?? ""} ${str(rec.team_title) ?? ""}`.toLowerCase().includes(needle)) return true;
954
+ }
955
+ return false;
956
+ });
957
+ }
958
+ /**
959
+ * Project a workout range to just the ids a set-log write needs, read from the SAME saved-copy
960
+ * location {@link findSavedWorkoutSet} matches against (`summarizedSavedWorkout.saved_workout`).
961
+ * This is the self-service path for logging: instead of grepping the multi-KB `--raw` blob for
962
+ * which of several id fields maps to `--set`, callers read `savedWorkoutSetId` and each
963
+ * `savedWorkoutSetExerciseId` straight off these rows. Each target also carries its program/team
964
+ * so a caller can pick the right session when the athlete is on several. One row per saved set,
965
+ * dropping any set with no resolvable id.
966
+ */
967
+ function presentLogTargets(list) {
968
+ const targets = [];
969
+ for (const pw of list) {
970
+ const rec = pw;
971
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
972
+ const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
973
+ if (!saved) continue;
974
+ const date = str(rec.date) ?? "";
975
+ const workoutTitle = str(rec.workout_title) ?? "";
976
+ const program = str(rec.program_title);
977
+ const programId = coerceInt(rec.program_id);
978
+ const team = str(rec.team_title);
979
+ const teamId = coerceInt(rec.team_id);
980
+ const sets = Array.isArray(saved.workoutSets) ? saved.workoutSets : [];
981
+ for (const s of sets) {
982
+ if (!isRecord(s)) continue;
983
+ const savedWorkoutSetId = coerceInt(s.id);
984
+ if (savedWorkoutSetId === null) continue;
985
+ const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
986
+ const id = coerceInt(ex.id);
987
+ if (id === null) return null;
988
+ return {
989
+ savedWorkoutSetExerciseId: id,
990
+ title: exerciseTitle(ex),
991
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type),
992
+ prescribed: prescribedSets(ex),
993
+ performed: performedSets(ex)
994
+ };
995
+ }).filter((e) => e !== null);
996
+ targets.push({
997
+ date,
998
+ workoutTitle,
999
+ program,
1000
+ programId,
1001
+ team,
1002
+ teamId,
1003
+ savedWorkoutSetId,
1004
+ setTitle: str(s.title),
1005
+ exercises
1006
+ });
1007
+ }
1008
+ }
1009
+ return targets;
1010
+ }
1011
+ /**
910
1012
  * Narrow a presented workout list for the common "what did I actually do" reads. `loggedOnly`
911
1013
  * keeps only workouts the athlete logged a set on (the reliable signal, not the API's
912
1014
  * completion flag). `limit` keeps the most recent N by date (newest first). Both are pure
@@ -934,6 +1036,7 @@ function summarizeAthleteWorkouts(list) {
934
1036
  program: w.program,
935
1037
  team: w.team,
936
1038
  logged: w.logged,
1039
+ personal: w.personal,
937
1040
  exerciseCount,
938
1041
  performedCount
939
1042
  };
@@ -1112,8 +1215,7 @@ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
1112
1215
  if (setId !== null) {
1113
1216
  const exLabels = exercises.map((ex) => {
1114
1217
  const exId = coerceInt(ex.id);
1115
- const title = typeof ex.exercise_title === "string" && ex.exercise_title || typeof ex.title === "string" && ex.title || "exercise";
1116
- return exId === null ? null : `${exId} (${title})`;
1218
+ return exId === null ? null : `${exId} (${exerciseTitle(ex, "exercise")})`;
1117
1219
  }).filter((x) => x !== null);
1118
1220
  available.push(`set ${setId} → exercise ids: ${exLabels.join(", ") || "none"}`);
1119
1221
  }
@@ -1228,8 +1330,7 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1228
1330
  if (!ex) {
1229
1331
  const valid = exercises.map((e) => {
1230
1332
  const id = coerceInt(e.id);
1231
- const title = typeof e.exercise_title === "string" && e.exercise_title || typeof e.title === "string" && e.title || "exercise";
1232
- return id === null ? null : `${id} (${title})`;
1333
+ return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
1233
1334
  }).filter((x) => x !== null);
1234
1335
  throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
1235
1336
  }
@@ -1304,17 +1405,30 @@ async function addExercisesToWorkout(client, workoutId, exercises) {
1304
1405
  return res.data;
1305
1406
  }
1306
1407
  /**
1408
+ * DELETE /v5/programWorkouts/{programWorkoutId} — remove a personal (athlete-created) workout
1409
+ * session from the athlete's own calendar. Re-reads the day and removes ONLY a personal session
1410
+ * (`personal_cal`): it throws if no workout with that id is on the date, or if the target is a
1411
+ * coach-scheduled workout (which is not the athlete's to delete). This guard lives here, at the
1412
+ * delete itself, so no caller can issue the DELETE against a coach workout by skipping a check.
1413
+ * Throws on a non-ok response.
1414
+ */
1415
+ async function removePersonalWorkout(client, args) {
1416
+ const target = (await fetchAthleteWorkouts(client, args.date, args.date)).find((pw) => coerceInt(pw.id) === args.programWorkoutId);
1417
+ if (target === void 0) throw new Error(`No workout with id ${args.programWorkoutId} on ${args.date}. Get the id and date from athlete_workouts.`);
1418
+ if (!isPersonalSession(target)) throw new Error(`Workout ${args.programWorkoutId} on ${args.date} is a coach-scheduled workout, not a personal session, so it can't be removed. To change logged results on a scheduled workout, use athlete_log_set.`);
1419
+ const res = await client.request("DELETE", `/v5/programWorkouts/${args.programWorkoutId}`);
1420
+ if (!res.ok) throw new Error(`Remove personal workout failed (HTTP ${res.status}).`);
1421
+ }
1422
+ /**
1307
1423
  * Find a personal-calendar session already on the given day. The range marks these with
1308
1424
  * `personal_cal === true`; the addable id is the program workout's `workout_id`. Returns the
1309
1425
  * first one's workoutId, or null when the day has no personal session.
1310
1426
  */
1311
1427
  function findPersonalSessionWorkoutId(workouts) {
1312
1428
  for (const pw of workouts) {
1313
- const rec = pw;
1314
- if (rec.personal_cal === true) {
1315
- const workoutId = coerceInt(rec.workout_id);
1316
- if (workoutId !== null) return workoutId;
1317
- }
1429
+ if (!isPersonalSession(pw)) continue;
1430
+ const workoutId = coerceInt(pw.workout_id);
1431
+ if (workoutId !== null) return workoutId;
1318
1432
  }
1319
1433
  return null;
1320
1434
  }
@@ -1345,6 +1459,59 @@ function indexAddedExercises(added) {
1345
1459
  }
1346
1460
  return out;
1347
1461
  }
1462
+ /**
1463
+ * Flatten a day's saved sets into `{ pw, savedWorkoutSetId, ex }` rows (both saved-set keys),
1464
+ * carrying the parent program-workout so a caller can read its identity (`personal_cal`, program
1465
+ * title) without re-walking. The one saved-copy traversal the by-exercise paths share.
1466
+ */
1467
+ function eachPrescribedExercise(workouts) {
1468
+ const rows = [];
1469
+ for (const pw of workouts) {
1470
+ const rec = pw;
1471
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
1472
+ const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
1473
+ if (!sw) continue;
1474
+ const sets = [...Array.isArray(sw.workoutSets) ? sw.workoutSets : [], ...Array.isArray(sw.addedWorkoutSets) ? sw.addedWorkoutSets : []];
1475
+ for (const s of sets) {
1476
+ if (!isRecord(s)) continue;
1477
+ const savedWorkoutSetId = coerceInt(s.id);
1478
+ if (savedWorkoutSetId === null) continue;
1479
+ const exercises = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
1480
+ for (const ex of exercises) if (isRecord(ex)) rows.push({
1481
+ pw: rec,
1482
+ savedWorkoutSetId,
1483
+ ex
1484
+ });
1485
+ }
1486
+ }
1487
+ return rows;
1488
+ }
1489
+ /**
1490
+ * Find prescribed exercises on coach-scheduled (non-personal) workouts that day matching any of the
1491
+ * given exercise ids. Used to warn after an ad-hoc log that the same lift was already on the
1492
+ * athlete's scheduled workout — a caller can then point at {@link logAthleteSet}. Personal sessions
1493
+ * (`personal_cal === true`) are skipped: the ad-hoc log itself lands there, so they are not an
1494
+ * alternative. One row per matching saved-set exercise, dropping any with an unresolvable id.
1495
+ */
1496
+ function findScheduledMatches(workouts, exerciseIds) {
1497
+ const out = [];
1498
+ for (const { pw, savedWorkoutSetId, ex } of eachPrescribedExercise(workouts)) {
1499
+ if (isPersonalSession(pw)) continue;
1500
+ const exerciseId = coerceInt(ex.exercise_id);
1501
+ const savedWorkoutSetExerciseId = coerceInt(ex.id);
1502
+ if (exerciseId === null || savedWorkoutSetExerciseId === null) continue;
1503
+ if (!exerciseIds.has(exerciseId)) continue;
1504
+ out.push({
1505
+ exerciseId,
1506
+ title: exerciseTitle(ex),
1507
+ program: str(pw.program_title),
1508
+ workoutTitle: str(pw.workout_title) ?? "",
1509
+ savedWorkoutSetId,
1510
+ savedWorkoutSetExerciseId
1511
+ });
1512
+ }
1513
+ return out;
1514
+ }
1348
1515
  /** Group resolved exercises by saved set and write each set via the given log target. */
1349
1516
  async function logResolvedExercises(client, target, date, resolved) {
1350
1517
  const bySet = /* @__PURE__ */ new Map();
@@ -1384,7 +1551,8 @@ async function logResolvedExercises(client, target, date, resolved) {
1384
1551
  */
1385
1552
  async function logAdHocSession(client, args) {
1386
1553
  if (args.exercises.length === 0) throw new Error("Provide at least one exercise to log.");
1387
- const existing = findPersonalSessionWorkoutId(await fetchAthleteWorkouts(client, args.date, args.date));
1554
+ const day = await fetchAthleteWorkouts(client, args.date, args.date);
1555
+ const existing = findPersonalSessionWorkoutId(day);
1388
1556
  const created = existing === null;
1389
1557
  const workoutId = existing ?? (await createPersonalWorkout(client, args.date)).workoutId;
1390
1558
  const withOrder = args.exercises.map((e, i) => ({
@@ -1408,11 +1576,30 @@ async function logAdHocSession(client, args) {
1408
1576
  };
1409
1577
  });
1410
1578
  const sets = await logResolvedExercises(client, { role: "athlete" }, args.date, resolved);
1411
- return {
1579
+ const result = {
1412
1580
  date: args.date,
1413
1581
  created,
1414
1582
  sets
1415
1583
  };
1584
+ const scheduledAlternatives = findScheduledMatches(day, new Set(args.exercises.map((e) => e.exerciseId)));
1585
+ if (scheduledAlternatives.length > 0) result.scheduledAlternatives = scheduledAlternatives;
1586
+ return result;
1587
+ }
1588
+ //#endregion
1589
+ //#region ../js/src/util.ts
1590
+ /**
1591
+ * Drop the keys whose value is `undefined`, so the result can be passed to a function or
1592
+ * spread into an object under `exactOptionalPropertyTypes` without the per-key
1593
+ * `...(x !== undefined ? { x } : {})` dance. Required keys stay required in the result type;
1594
+ * optional ones lose `undefined` from their value type.
1595
+ *
1596
+ * Example:
1597
+ * definedProps({ metric, teamId: maybeId, date }) // omits teamId/date if they're undefined
1598
+ */
1599
+ function definedProps(obj) {
1600
+ const result = {};
1601
+ for (const [key, value] of Object.entries(obj)) if (value !== void 0) result[key] = value;
1602
+ return result;
1416
1603
  }
1417
1604
  //#endregion
1418
1605
  //#region ../core/src/history.ts
@@ -1490,6 +1677,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
1490
1677
  }));
1491
1678
  }
1492
1679
  const ATHLETE_WORKOUTS_DESC = "Workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises. Each exercise carries both its `prescribed` sets (what the program called for) and `performed` sets (what the athlete actually logged); each workout has a top-level `logged` flag. Use `performed`/`logged` to tell what was recorded or done. That is the reliable signal: a session can hold logged sets while the API's own completion flags stay 0, and an empty `performed` means nothing was logged for that exercise. For 'did I record anything / what did I do', set loggedOnly:true to return only sessions with logged sets (it keeps whole sessions that have any logged set, so individual exercises inside can still show empty `performed`; it also shrinks a large result); limit returns the most recent N workouts (newest first). For a high-level overview ('what's on my schedule this week', 'what have I been training lately') set summary:true to get one compact row per session (date, program, title, logged flag, and exerciseCount/performedCount — read performedCount against exerciseCount, e.g. 1 of 12 logged) instead of every set — a multi-program week of full detail is large, so prefer summary first, then re-query a single day without it to drill into that day's sets. For 'what's my next workout', query a forward window from today; if it comes back empty, the most recent session is likely yesterday's still-unlogged one, so widen the window backward a day or two. Both filters apply to the presented view, not raw. raw:true returns the untouched API objects. This is a date-windowed fetch, NOT an aggregate: for lifetime totals (all-time session count, total volume, first/last logged date) call athlete_profile instead of summing windows — a multi-year range here can time out. Narrow the window if the result is truncated.";
1680
+ const ATHLETE_LOG_TARGETS_DESC = "The savedWorkoutSetId + savedWorkoutSetExerciseId that athlete_log_set needs, read straight off your scheduled/logged workouts in an inclusive YYYY-MM-DD window — no raw needed. This is the self-service path for logging into a COACH-SCHEDULED workout: read the ids here, then pass them to athlete_log_set. The default view is COMPACT: one row per saved set, each carrying its program/programId, the savedWorkoutSetId, and every exercise's savedWorkoutSetExerciseId with prescribed/performed values. When several workouts fall on the same day (you're on more than one program), narrow to one with program (a case-insensitive title substring, e.g. 'bodybuilding' — no id lookup needed), or programId/teamId if you have the id. raw:true returns the untouched API objects, but that blob is large and can be truncated when several workouts share a date — prefer the program filter + the default view. For reading what you actually did (not the log ids), use athlete_workouts.";
1493
1681
  const ATHLETE_EXERCISE_HISTORY_DESC = "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). The returned `sessions` run all-time, newest first; pass since/until (YYYY-MM-DD, inclusive) to keep only sessions in that window — use it for 'last 3 months' / 'this year' questions so the result stays small. estimated1RM is a formula off the session's best set and reads high when a session mixes a heavy single with high-rep backdown work, so treat it as approximate, not a logged single. `liftPRs` are always all-time and ignore since/until, but each carries the date it was set, so filter those dates yourself to answer 'PRs set this year'. Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.";
1494
1682
  const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name; use the returned id with athlete_exercise_history, athlete_personal_records, or athlete_exercise_stats (all of which require an exercise id). A common name returns several variants (e.g. plain 'Bench Press' id 1162 vs 'BARBELL BENCH PRESS'); each variant keeps its own separate history and PR board, so prefer the plain canonical entry, and if a PR or trend looks incomplete, the work may be split across variants — check the others.";
1495
1683
  function runAthleteWorkouts(ctx, args) {
@@ -1567,7 +1755,31 @@ function registerExerciseTools(server, ctx, userId) {
1567
1755
  annotations: READ
1568
1756
  }, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
1569
1757
  }
1570
- /** Create a personal workout session and add exercises to it. */
1758
+ /** The compact log-id read: savedWorkoutSetId + savedWorkoutSetExerciseId for athlete_log_set. */
1759
+ function registerLogTargetsTool(server, ctx) {
1760
+ server.registerTool("athlete_log_targets", {
1761
+ title: "Saved-workout log ids (for athlete_log_set)",
1762
+ description: ATHLETE_LOG_TARGETS_DESC,
1763
+ inputSchema: {
1764
+ startDate: dateString,
1765
+ endDate: dateString,
1766
+ program: z.string().optional(),
1767
+ programId: idParam.optional(),
1768
+ teamId: idParam.optional(),
1769
+ raw: z.boolean().optional()
1770
+ },
1771
+ annotations: READ
1772
+ }, ({ startDate, endDate, program, programId, teamId, raw }) => attempt(async () => {
1773
+ const workouts = selectWorkoutsByProgram(await fetchAthleteWorkouts(ctx.client, startDate, endDate), definedProps({
1774
+ programTitle: program,
1775
+ programId: programId === void 0 ? void 0 : toId(programId),
1776
+ teamId: teamId === void 0 ? void 0 : toId(teamId)
1777
+ }));
1778
+ if (raw === true) return jsonResult(workouts, { hint: "Each set's id is the savedWorkoutSetId; each savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId. Large — if truncated, pass program (a title substring) or use the default compact view." });
1779
+ return jsonResult(presentLogTargets(workouts), { hint: "One row per saved set: savedWorkoutSetId plus each savedWorkoutSetExerciseId, with the program it belongs to. Pass both to athlete_log_set. Filter with program (title substring), programId, or teamId if you're on several programs." });
1780
+ }));
1781
+ }
1782
+ /** Create a personal workout session, add exercises, and remove a stray personal session. */
1571
1783
  function registerSessionTools(server, ctx) {
1572
1784
  server.registerTool("athlete_session_create", {
1573
1785
  title: "Create personal workout session",
@@ -1601,6 +1813,27 @@ function registerSessionTools(server, ctx) {
1601
1813
  }));
1602
1814
  return jsonResult(await addExercisesToWorkout(ctx.client, toId(workoutId), mapped), { hint: "Each top-level id is a savedWorkoutSetId; savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId for athlete_log_set." });
1603
1815
  }));
1816
+ server.registerTool("athlete_session_remove", {
1817
+ title: "Remove a personal workout session",
1818
+ description: "Delete a personal (self-created) workout session from your calendar — use it to clean up a stray ad-hoc session, e.g. one athlete_log_session created when you meant to log into a coach-scheduled workout. Give the programWorkoutId (the `id` of the session in athlete_workouts, where personal:true marks a personal session) and its date. ONLY personal sessions can be removed: the tool re-reads that day and refuses a coach-scheduled workout. Requires confirmation (elicitation or confirm:true).",
1819
+ inputSchema: {
1820
+ ...athleteSessionRemoveArgsSchema.shape,
1821
+ confirm: z.boolean().optional()
1822
+ },
1823
+ annotations: DESTRUCTIVE
1824
+ }, ({ programWorkoutId, date, confirm }, extra) => attempt(async () => {
1825
+ const id = toId(programWorkoutId);
1826
+ if (!await confirmGate(server, extra.requestId, `Permanently remove personal session ${id} on ${date}? This deletes the session and anything logged in it.`, confirm)) return errorResult(NOT_CONFIRMED);
1827
+ await removePersonalWorkout(ctx.client, {
1828
+ programWorkoutId: id,
1829
+ date
1830
+ });
1831
+ return jsonResult({
1832
+ removed: true,
1833
+ programWorkoutId: id,
1834
+ date
1835
+ });
1836
+ }));
1604
1837
  }
1605
1838
  /** Map validated logSession exercises to the SDK's SessionExercise[] (ids coerced, slots trimmed). */
1606
1839
  function mapSessionExercises(exercises) {
@@ -1631,14 +1864,16 @@ function registerLogTool(server, ctx) {
1631
1864
  annotations: DESTRUCTIVE
1632
1865
  }, ({ date, exercises, confirm }, extra) => attempt(async () => {
1633
1866
  if (!await confirmGate(server, extra.requestId, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
1634
- return jsonResult(await logAdHocSession(ctx.client, {
1867
+ const res = await logAdHocSession(ctx.client, {
1635
1868
  date,
1636
1869
  exercises: mapSessionExercises(exercises)
1637
- }));
1870
+ });
1871
+ if (res.scheduledAlternatives !== void 0 && res.scheduledAlternatives.length > 0) return jsonResult(res, { hint: "Heads up: one or more of these lifts were already on a coach-scheduled workout today (see scheduledAlternatives). This logged a SEPARATE personal session. To log into the scheduled workout instead, use athlete_log_set with the savedWorkoutSetId / savedWorkoutSetExerciseId shown there — and athlete_session_remove to delete this personal session if it was a mistake." });
1872
+ return jsonResult(res);
1638
1873
  }));
1639
1874
  server.registerTool("athlete_log_set", {
1640
1875
  title: "Log completed set results",
1641
- description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
1876
+ description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_log_targets (filter by program when several workouts share a date). Requires confirmation (elicitation or confirm:true).",
1642
1877
  inputSchema: {
1643
1878
  ...logSetArgsSchema.shape,
1644
1879
  confirm: z.boolean().optional()
@@ -1675,12 +1910,13 @@ function registerAthleteTrainingTools(server, ctx) {
1675
1910
  };
1676
1911
  registerProfileTools(server, ctx, whoami, userId);
1677
1912
  registerExerciseTools(server, ctx, userId);
1913
+ registerLogTargetsTool(server, ctx);
1678
1914
  registerSessionTools(server, ctx);
1679
1915
  registerLogTool(server, ctx);
1680
1916
  }
1681
1917
  //#endregion
1682
1918
  //#region package.json
1683
- var version = "1.5.0";
1919
+ var version = "1.6.0";
1684
1920
  //#endregion
1685
1921
  //#region src/server.ts
1686
1922
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.29.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "1.5.0",
25
- "@trainheroic-unofficial/js": "1.5.0"
24
+ "@trainheroic-unofficial/core": "1.6.0",
25
+ "@trainheroic-unofficial/js": "1.6.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",