incremnt 0.8.3 → 0.8.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ask-coach.js CHANGED
@@ -539,18 +539,18 @@ function pushAskContextHeader(lines, snapshot, today = new Date()) {
539
539
  const ASK_FACT_KIND_BY_ROUTE = Object.freeze({
540
540
  general: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
541
541
  progress_review: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
542
- exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference'],
543
- exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference'],
544
- program_progress: ['goal_signal', 'preference', 'constraint', 'injury'],
542
+ exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
543
+ exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
544
+ program_progress: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
545
545
  training_profile: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
546
- program_design: ['goal_signal', 'preference', 'constraint', 'injury'],
547
- next_session: ['constraint', 'injury', 'preference', 'goal_signal'],
548
- recent_session: ['injury', 'constraint', 'goal_signal'],
546
+ program_design: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
547
+ next_session: ['constraint', 'injury', 'preference', 'goal_signal', 'tone'],
548
+ recent_session: ['injury', 'constraint', 'goal_signal', 'tone'],
549
549
  recovery: ['injury', 'constraint', 'tone'],
550
- body_weight: ['goal_signal'],
551
- volume: ['goal_signal', 'constraint'],
552
- records: ['goal_signal'],
553
- score: ['goal_signal', 'constraint']
550
+ body_weight: ['goal_signal', 'tone'],
551
+ volume: ['goal_signal', 'constraint', 'tone'],
552
+ records: ['goal_signal', 'tone'],
553
+ score: ['goal_signal', 'constraint', 'tone']
554
554
  });
555
555
 
556
556
  function normalizeCoachFactForContext(row) {
@@ -611,6 +611,13 @@ function appendCoachFactsContext(lines, facts) {
611
611
  .join(', ');
612
612
  lines.push(` [${fact.kind}] ${fact.fact}${provenance ? ` (${provenance})` : ''}`);
613
613
  }
614
+ const toneFacts = facts
615
+ .filter((fact) => fact.kind === 'tone')
616
+ .map((fact) => fact.fact)
617
+ .filter(Boolean);
618
+ if (toneFacts.length > 0) {
619
+ lines.push(`Answer style preference to carry explicitly: ${toneFacts.join(' ')}`);
620
+ }
614
621
  return facts.map((fact) => fact.id).filter(Boolean);
615
622
  }
616
623
 
@@ -857,17 +864,29 @@ function appendReadinessSummary(lines, readiness) {
857
864
  // Per-route prose builders that compose tool results into the routed
858
865
  // Ask Coach context, attaching provenance for each section.
859
866
 
867
+ function appendWeeklyVolumeEvidence(lines, weeklyVolume) {
868
+ const facts = weeklyVolume?.facts ?? {};
869
+ const isPartial = facts.currentWeekIsPartial === true;
870
+ const currentLabel = isPartial ? 'Week-to-date strength volume' : 'This week strength volume';
871
+ const previousLabel = 'Previous full week strength volume';
872
+ lines.push(`${currentLabel}: ${facts.currentWeekVolume} kg across ${facts.currentWeekSessionCount} session${facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
873
+ lines.push(`${previousLabel}: ${facts.previousWeekVolume} kg across ${facts.previousWeekSessionCount} session${facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
874
+ if (facts.deltaPct != null) {
875
+ const prefix = facts.deltaPct >= 0 ? '+' : '';
876
+ const comparison = isPartial
877
+ ? 'Week-to-date versus previous full week strength volume'
878
+ : 'Week-over-week strength volume change';
879
+ lines.push(`${comparison}: ${prefix}${facts.deltaPct}%.`);
880
+ }
881
+ }
882
+
860
883
  function buildVolumeAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
861
884
  const lines = [];
862
885
  const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
863
886
  pushAskContextHeader(lines, snapshot, today);
864
887
 
865
888
  lines.push('');
866
- lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
867
- lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
868
- if (weeklyVolume.facts.deltaPct != null) {
869
- lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
870
- }
889
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
871
890
  const thisWeekRows = weeklyVolume.rows.filter((row) => row.week === 'current');
872
891
  if (thisWeekRows.length > 0) {
873
892
  lines.push('This week sessions:');
@@ -911,10 +930,48 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
911
930
  }
912
931
 
913
932
  function formattedCompletedSets(sets = []) {
914
- return sets.map((set) => {
933
+ const groups = [];
934
+ for (const set of sets) {
935
+ const weight = Number(set.weight) || 0;
936
+ const label = weight > 0 ? `${formatCompactWeight(weight)}kg` : 'BW';
937
+ const previous = groups.at(-1);
938
+ if (previous?.label === label) {
939
+ previous.reps.push(set.reps);
940
+ } else {
941
+ groups.push({ label, reps: [set.reps] });
942
+ }
943
+ }
944
+ return groups.map((group) => `${group.label}: ${group.reps.join('/')}`).join(', ');
945
+ }
946
+
947
+ function formattedCompletedSetShorthand(sets = []) {
948
+ const groups = [];
949
+ for (const set of sets) {
915
950
  const weight = Number(set.weight) || 0;
916
- return weight > 0 ? `${weight.toFixed(1)}x${set.reps}` : `BWx${set.reps}`;
917
- }).join(', ');
951
+ const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
952
+ const previous = groups.at(-1);
953
+ if (previous?.label === label) {
954
+ previous.reps.push(set.reps);
955
+ } else {
956
+ groups.push({ label, reps: [set.reps] });
957
+ }
958
+ }
959
+ return groups.map((group) => `${group.label}x${group.reps.join('/')}`).join(', ');
960
+ }
961
+
962
+ function formattedSetShorthandList(sets = []) {
963
+ return sets
964
+ .map((set) => {
965
+ const weight = Number(set.weight) || 0;
966
+ const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
967
+ return `${label}x${set.reps}`;
968
+ })
969
+ .join(', ');
970
+ }
971
+
972
+ function formatCompactWeight(weight) {
973
+ const rounded = Math.round(Number(weight) * 10) / 10;
974
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
918
975
  }
919
976
 
920
977
  function formatRepDelta(delta) {
@@ -971,6 +1028,11 @@ function formatComparableSetDelta(exercise) {
971
1028
  if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
972
1029
  details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
973
1030
  }
1031
+ const currentSetList = formattedSetShorthandList(currentSets);
1032
+ const previousSetList = formattedSetShorthandList(previousSets);
1033
+ if (currentSetList && previousSetList) {
1034
+ details.push(`current sets ${currentSetList}; previous sets ${previousSetList}`);
1035
+ }
974
1036
  if (regressionFlag) {
975
1037
  details.push('regression flag: reps dropped sharply despite the load/set context');
976
1038
  }
@@ -1079,7 +1141,9 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
1079
1141
  for (const row of exerciseHistoryTool.rows) {
1080
1142
  const comparison = formatTopSetComparison(row);
1081
1143
  const warmups = row.warmupSetCount > 0 ? `; ${row.warmupSetCount} warmup set${row.warmupSetCount === 1 ? '' : 's'} excluded` : '';
1082
- lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${comparison ? `; ${comparison}` : ''}${warmups}`);
1144
+ const shorthand = formattedCompletedSetShorthand(row.sets);
1145
+ lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${shorthand ? ` (compact: ${shorthand})` : ''}${comparison ? `; ${comparison}` : ''}${warmups}`);
1146
+ if (row.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(row.recommendation)}`);
1083
1147
  }
1084
1148
  appendExerciseHistoryNotes(lines, exerciseHistoryTool.rows);
1085
1149
  }
@@ -1091,9 +1155,21 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
1091
1155
 
1092
1156
  function formatProgressPoint(point) {
1093
1157
  if (!point) return 'unknown';
1094
- const load = Number(point.weight) > 0 ? `${point.weight}x${point.reps}` : `BWx${point.reps}`;
1095
- const metric = point.progressMetric === 'reps' ? `reps ${point.progressValue ?? point.reps}` : `e1RM ${point.e1rm}`;
1096
- return `${point.date}: ${load} (${metric})`;
1158
+ const load = Number(point.weight) > 0 ? `${formatCompactWeight(point.weight)}x${point.reps}` : `BWx${point.reps}`;
1159
+ return `${point.date}: ${load}`;
1160
+ }
1161
+
1162
+ function progressVerdict(row) {
1163
+ const bestDelta = Number(row?.bestDeltaFromFirst);
1164
+ const latestDelta = Number(row?.latestDeltaFromFirst);
1165
+ const latestFromBest = Number(row?.latestDeltaFromBest);
1166
+ if (Number.isFinite(bestDelta) && bestDelta > 0 && Number.isFinite(latestFromBest) && latestFromBest < 0) {
1167
+ return 'peaked early, then dipped';
1168
+ }
1169
+ if (Number.isFinite(latestDelta) && latestDelta > 0) return 'latest is up from first';
1170
+ if (Number.isFinite(latestDelta) && latestDelta < 0) return 'latest is down from first';
1171
+ if (Number.isFinite(latestDelta)) return 'latest matches first';
1172
+ return null;
1097
1173
  }
1098
1174
 
1099
1175
  function appendProgressSummaryRows(lines, rows = []) {
@@ -1111,7 +1187,8 @@ function appendProgressSummaryRows(lines, rows = []) {
1111
1187
  const latestDropFromBest = row.latestDeltaFromBest == null
1112
1188
  ? ''
1113
1189
  : `, latest vs best ${row.latestDeltaFromBest > 0 ? '+' : ''}${row.latestDeltaFromBest}`;
1114
- lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}.`);
1190
+ const verdict = progressVerdict(row);
1191
+ lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}${verdict ? `; tracking verdict: ${verdict}` : ''}.`);
1115
1192
  }
1116
1193
  }
1117
1194
 
@@ -1345,9 +1422,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1345
1422
  exclude = new Set(),
1346
1423
  today = new Date(),
1347
1424
  namedExercises = [],
1348
- existingSections = []
1425
+ existingSections = [],
1426
+ omitSections = []
1349
1427
  } = {}) {
1350
1428
  const sections = new Set(existingSections);
1429
+ const omitted = new Set(omitSections);
1351
1430
  const note = buildExcludeNote(exclude);
1352
1431
  const noteAtEnd = note && lines.at(-1) === note;
1353
1432
  if (noteAtEnd) {
@@ -1364,24 +1443,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1364
1443
  addedSections.push(section);
1365
1444
  };
1366
1445
 
1367
- if (!sections.has('increment_score')) {
1446
+ if (!sections.has('increment_score') && !omitted.has('increment_score')) {
1368
1447
  const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
1369
1448
  appendIncrementScoreEvidence(lines, incrementScore);
1370
1449
  addTool('increment_score', incrementScore);
1371
1450
  }
1372
1451
 
1373
- if (!sections.has('weekly_volume')) {
1452
+ if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
1374
1453
  const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
1375
1454
  lines.push('');
1376
- lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
1377
- lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
1378
- if (weeklyVolume.facts.deltaPct != null) {
1379
- lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
1380
- }
1455
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1381
1456
  addTool('weekly_volume', weeklyVolume);
1382
1457
  }
1383
1458
 
1384
- if (!sections.has('records')) {
1459
+ if (!sections.has('records') && !omitted.has('records')) {
1385
1460
  const records = executeCoachReadTool(snapshot, 'get_records', {
1386
1461
  exercises: namedExercises,
1387
1462
  limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
@@ -1391,7 +1466,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1391
1466
  addTool('records', records);
1392
1467
  }
1393
1468
 
1394
- if (!sections.has('body_weight')) {
1469
+ if (!sections.has('body_weight') && !omitted.has('body_weight')) {
1395
1470
  const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
1396
1471
  recentDays: 30,
1397
1472
  exclude: [...exclude],
@@ -1401,7 +1476,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1401
1476
  addTool('body_weight', bodyWeight);
1402
1477
  }
1403
1478
 
1404
- if (!sections.has('readiness')) {
1479
+ if (!sections.has('readiness') && !omitted.has('readiness')) {
1405
1480
  const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
1406
1481
  recentDays: 14,
1407
1482
  exclude: [...exclude],
@@ -1411,7 +1486,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1411
1486
  addTool('readiness', readiness);
1412
1487
  }
1413
1488
 
1414
- if (!sections.has('goal_status')) {
1489
+ if (!sections.has('goal_status') && !omitted.has('goal_status')) {
1415
1490
  const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
1416
1491
  appendGoalStatusEvidence(lines, goalStatus);
1417
1492
  addTool('goal_status', goalStatus);
@@ -1468,6 +1543,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
1468
1543
  const setsStr = formattedCompletedSets(exercise.sets);
1469
1544
  const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
1470
1545
  if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
1546
+ if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
1471
1547
  const setDelta = formatComparableSetDelta(exercise);
1472
1548
  if (setDelta) lines.push(` ${setDelta}`);
1473
1549
  }
@@ -1636,11 +1712,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
1636
1712
 
1637
1713
  // Weekly volume with week-over-week direction.
1638
1714
  lines.push('');
1639
- lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
1640
- lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
1641
- if (weeklyVolume.facts.deltaPct != null) {
1642
- lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
1643
- }
1715
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1644
1716
 
1645
1717
  // Per-session top sets so the model can see real progression, not just names.
1646
1718
  const recent = recentSessions.rows.slice().reverse();
@@ -2048,7 +2120,7 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
2048
2120
  volume: ['What should I do next session?', 'Is this too much weekly volume?'],
2049
2121
  next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
2050
2122
  recovery: ['Should I train tomorrow?', 'What would be a conservative version?'],
2051
- recent_session: ['Why did that session feel hard?', 'What should I change next time?'],
2123
+ recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
2052
2124
  body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
2053
2125
  score: ['What is pulling my score down?', 'What should I focus on this week?'],
2054
2126
  program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
@@ -2229,6 +2301,7 @@ function appendAskAnswerContract(lines, {
2229
2301
  namedExerciseLabels = [],
2230
2302
  builtTools = [],
2231
2303
  sessionObservationComparisons = [],
2304
+ includedFacts = [],
2232
2305
  question = ''
2233
2306
  } = {}) {
2234
2307
  const note = buildExcludeNote(new Set());
@@ -2241,6 +2314,15 @@ function appendAskAnswerContract(lines, {
2241
2314
  const contract = [];
2242
2315
  const fullExerciseNames = namedExerciseLabels.filter(Boolean);
2243
2316
  const text = String(question ?? '').toLowerCase();
2317
+ const toneFacts = includedFacts
2318
+ .filter((fact) => fact.kind === 'tone')
2319
+ .map((fact) => fact.fact)
2320
+ .filter(Boolean);
2321
+
2322
+ if (fullExerciseNames.length > 0) {
2323
+ contract.push('Answer contract: named exercise identity.');
2324
+ contract.push(` Use the exact exercise name(s): ${fullExerciseNames.join(', ')}.`);
2325
+ }
2244
2326
 
2245
2327
  if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
2246
2328
  contract.push('Answer contract: defensive decision.');
@@ -2248,12 +2330,17 @@ function appendAskAnswerContract(lines, {
2248
2330
  contract.push(' Name the relevant exercise exactly as written in the evidence.');
2249
2331
  contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
2250
2332
  contract.push(' Do not mention record estimates or PRs unless the user explicitly asked about them.');
2251
- contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent"; say "latest logged" or give the days-ago label.');
2333
+ contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent" anywhere; say "latest logged", "stale", or give the days-ago label.');
2252
2334
  if (fullExerciseNames.length > 0) {
2253
2335
  contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
2254
2336
  }
2255
2337
  }
2256
2338
 
2339
+ if (toneFacts.some((fact) => /\bconcise\b/i.test(fact))) {
2340
+ contract.push('Answer contract: typed tone fact.');
2341
+ contract.push(' Mention the user preference for concise coaching cues using the word "concise".');
2342
+ }
2343
+
2257
2344
  if (route === 'progress_review') {
2258
2345
  const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
2259
2346
  const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
@@ -2263,6 +2350,7 @@ function appendAskAnswerContract(lines, {
2263
2350
  const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
2264
2351
  const strengthSessionCount = recentSessions?.rows?.length;
2265
2352
  const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
2353
+ const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
2266
2354
  const recentRecordCount = records?.facts?.recentRecordCount;
2267
2355
  const readinessFacts = readiness?.facts ?? {};
2268
2356
  const readinessPhrases = [
@@ -2276,10 +2364,14 @@ function appendAskAnswerContract(lines, {
2276
2364
  if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
2277
2365
  contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
2278
2366
  }
2279
- if (Number.isFinite(Number(currentSessions)) && Number.isFinite(Number(previousSessions))) {
2367
+ if (
2368
+ Number.isFinite(Number(currentSessions))
2369
+ && Number.isFinite(Number(previousSessions))
2370
+ && Number(currentSessions) === Number(previousSessions)
2371
+ ) {
2280
2372
  contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
2281
2373
  }
2282
- if (Number.isFinite(Number(volumeDeltaPct))) {
2374
+ if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
2283
2375
  const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
2284
2376
  contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
2285
2377
  }
@@ -2306,7 +2398,7 @@ function appendAskAnswerContract(lines, {
2306
2398
  contract.push('Answer contract: verify the alleged drop-off against logged sets.');
2307
2399
  contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
2308
2400
  contract.push(' If current working top load is higher than the prior comparable session, say it increased and do not describe the lift as declining.');
2309
- contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "regress", or "regressing" in the answer.');
2401
+ contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "falling", "regress", or "regressing" in the answer.');
2310
2402
  contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
2311
2403
  contract.push(' Do not mention record estimates unless the user asked for them.');
2312
2404
  }
@@ -2388,6 +2480,44 @@ function compactExerciseRows(items, key = 'exercise') {
2388
2480
  .map((name) => compactEvidenceRow(name, 'relevant lift history available'));
2389
2481
  }
2390
2482
 
2483
+ function muscleVolumeTrendEvidenceRows(evidence) {
2484
+ const rows = Array.isArray(evidence?.muscles)
2485
+ ? evidence.muscles
2486
+ : (evidence?.muscle ? [{ muscle: evidence.muscle }] : []);
2487
+ const isPartial = evidence?.currentWeek?.isPartial === true || evidence?.currentWeekIsPartial === true;
2488
+ return rows
2489
+ .slice(0, 3)
2490
+ .map((row) => {
2491
+ const muscle = String(row?.muscle ?? '').trim();
2492
+ if (!muscle) return null;
2493
+ return compactEvidenceRow(muscle, muscleVolumeTrendEvidenceValue(row, { isPartial }));
2494
+ })
2495
+ .filter(Boolean);
2496
+ }
2497
+
2498
+ function muscleVolumeTrendEvidenceValue(row, { isPartial = false } = {}) {
2499
+ const pieces = [];
2500
+ const share = wholePercent(row?.latestSharePct);
2501
+ if (share) pieces.push(`${share} of ${isPartial ? 'this week-to-date volume' : "this week's volume"}`);
2502
+ const delta = directionalPercent(row?.deltaVsPriorAvgPct);
2503
+ if (delta) pieces.push(delta);
2504
+ return pieces.length > 0 ? pieces.join(' · ') : 'muscle breakdown available';
2505
+ }
2506
+
2507
+ function wholePercent(value) {
2508
+ const number = Number(value);
2509
+ if (!Number.isFinite(number)) return null;
2510
+ return `${Math.round(number)}%`;
2511
+ }
2512
+
2513
+ function directionalPercent(value) {
2514
+ const number = Number(value);
2515
+ if (!Number.isFinite(number)) return null;
2516
+ const rounded = Math.round(number);
2517
+ if (rounded === 0) return 'flat versus recent weeks';
2518
+ return `${rounded > 0 ? 'up' : 'down'} ${Math.abs(rounded)}%`;
2519
+ }
2520
+
2391
2521
  function humanObservationEvidenceRows(observation) {
2392
2522
  const evidence = observation?.evidence;
2393
2523
  if (!evidence || typeof evidence !== 'object') return [];
@@ -2399,6 +2529,8 @@ function humanObservationEvidenceRows(observation) {
2399
2529
  rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
2400
2530
  }
2401
2531
  rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
2532
+ } else if (kind === 'muscle_volume_trend') {
2533
+ rows.push(...muscleVolumeTrendEvidenceRows(evidence));
2402
2534
  } else if (kind === 'training_balance_skew') {
2403
2535
  rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
2404
2536
  rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
@@ -2971,7 +3103,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2971
3103
  exclude,
2972
3104
  today,
2973
3105
  namedExercises: namedExerciseItems,
2974
- existingSections: built.sections
3106
+ existingSections: built.sections,
3107
+ omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
2975
3108
  })
2976
3109
  : { sections: [], tools: [], provenance: [] };
2977
3110
  built = {
@@ -3020,6 +3153,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3020
3153
  namedExerciseLabels,
3021
3154
  builtTools: tools,
3022
3155
  sessionObservationComparisons,
3156
+ includedFacts,
3023
3157
  question
3024
3158
  });
3025
3159
  const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
package/src/openrouter.js CHANGED
@@ -29,8 +29,8 @@ export const AI_PROMPT_VERSIONS = Object.freeze({
29
29
  cycle: 'cycle_v2026_04_18_1',
30
30
  vitals: 'vitals_v2026_04_16_1',
31
31
  checkpoint: 'checkpoint_v2026_04_16_1',
32
- ask: 'ask_v2026_06_02_1',
33
- askAgentic: 'ask_agentic_v2026_06_02_1',
32
+ ask: 'ask_v2026_06_13_1',
33
+ askAgentic: 'ask_agentic_v2026_06_13_1',
34
34
  weeklyCheckin: 'weekly_checkin_v2026_04_23_1',
35
35
  coachCommitments: 'coach_commitments_v2026_04_25_1',
36
36
  coachFacts: 'coach_facts_v2026_04_25_1'
@@ -1457,7 +1457,7 @@ const ASK_CORE_RULES = `Core rules:
1457
1457
  const ASK_EXPANSIVE_RULES = `Default Ask Coach style:
1458
1458
  - Give the rich version by default: warm, detailed, specific, and data-dense, even for vague questions like "how am I doing?" or "tell me nice things".
1459
1459
  - Volunteer useful score evidence when provided: rounded Increment Score headline, direction (up/down/flat — not the point-delta number), and positive/negative drivers. Never recite score sub-scores, decimals, daily score lists, or a day-over-day delta number.
1460
- - Volunteer useful records, PRs, and e1RMs when provided, but only when the routed evidence includes actual record rows and the answer is not a sparse-data uncertainty answer. Use them as evidence, not hype. Call a record value an estimated 1RM (e1RM), never a lifted set load.
1460
+ - Volunteer records, PRs, and e1RMs only when the user asks about records, PRs, maxes, strongest lifts, or strength milestones. For session reviews, missed targets, and next-session load decisions, prioritize plan targets, logged reps, and persisted recommendations; do not mention e1RM unless the user explicitly asks for it. Call a record value an estimated 1RM (e1RM), never a lifted set load.
1461
1461
  - For broad reads, synthesize sessions, volume, score drivers, records, body weight, readiness, goals, standouts, regressions, and caveats. Do not punt to a follow-up when the evidence is already present.
1462
1462
  - For session recaps, name the best real parts and the meaningful regression or watch item if one exists. Extra detail is good when it helps the user understand the workout.
1463
1463
  - Be concise only if the user asks for a quick answer or selected a concise tone.`;
@@ -22,6 +22,24 @@ export const PROMPT_CHANGELOG_TYPES = Object.freeze([
22
22
  ]);
23
23
 
24
24
  export const PROMPT_CHANGELOG = Object.freeze([
25
+ {
26
+ version: 'ask_agentic_v2026_06_13_1',
27
+ surface: 'askAgentic',
28
+ date: '2026-06-13',
29
+ type: 'safety',
30
+ summary:
31
+ 'Gate estimated 1RM/PR/record language to explicit max, PR, record, strongest-lift, or strength-milestone questions. Session reviews, missed-target answers, and next-session load decisions should prioritize plan targets, logged reps, and persisted recommendations instead of volunteering e1RM.',
32
+ eval: 'ask_target_miss_incline_bench'
33
+ },
34
+ {
35
+ version: 'ask_v2026_06_13_1',
36
+ surface: 'ask',
37
+ date: '2026-06-13',
38
+ type: 'safety',
39
+ summary:
40
+ 'Gate estimated 1RM/PR/record language to explicit max, PR, record, strongest-lift, or strength-milestone questions. Session reviews, missed-target answers, and next-session load decisions should prioritize plan targets, logged reps, and persisted recommendations instead of volunteering e1RM.',
41
+ eval: 'ask_target_miss_incline_bench'
42
+ },
25
43
  {
26
44
  version: 'ask_agentic_v2026_06_02_1',
27
45
  surface: 'askAgentic',
package/src/queries.js CHANGED
@@ -2717,6 +2717,7 @@ function coachToolResult(toolName, params, {
2717
2717
  export function getWeeklyVolume(snapshot, { today = new Date() } = {}) {
2718
2718
  const todayIso = dateOnlyString(today);
2719
2719
  const weekStart = startOfCurrentIsoWeek(today);
2720
+ const currentWeekEnd = isoDateOffset(weekStart, 6);
2720
2721
  const previousWeekEnd = new Date(new Date(`${weekStart}T00:00:00.000Z`).getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
2721
2722
  const previousWeekStart = new Date(new Date(`${weekStart}T00:00:00.000Z`).getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
2722
2723
  const thisWeek = sessionsInDateRange(snapshot, weekStart, todayIso);
@@ -2755,6 +2756,9 @@ export function getWeeklyVolume(snapshot, { today = new Date() } = {}) {
2755
2756
  currentWeekSessionCount: thisWeek.length,
2756
2757
  previousWeekVolume: Math.round(previousWeekVolume),
2757
2758
  previousWeekSessionCount: previousWeek.length,
2759
+ currentWeekEnd,
2760
+ currentWeekIsPartial: todayIso < currentWeekEnd,
2761
+ currentWeekObservedThrough: todayIso,
2758
2762
  deltaPct: previousWeekVolume > 0 ? Math.round(((thisWeekVolume - previousWeekVolume) / previousWeekVolume) * 100) : null
2759
2763
  },
2760
2764
  sourceIds: rows.map((row) => row.sessionId),
@@ -2798,6 +2802,7 @@ function isoDateOffset(isoDate, days) {
2798
2802
  export function getMuscleVolumeTrend(snapshot, { today = new Date(), weeks = 4 } = {}) {
2799
2803
  const todayIso = dateOnlyString(today);
2800
2804
  const currentWeekStart = startOfCurrentIsoWeek(today);
2805
+ const currentWeekEnd = isoDateOffset(currentWeekStart, 6);
2801
2806
  const boundedWeeks = Math.max(1, Math.min(12, Math.round(Number(weeks) || 4)));
2802
2807
 
2803
2808
  // Oldest -> newest so downstream arrays read chronologically.
@@ -2875,6 +2880,12 @@ export function getMuscleVolumeTrend(snapshot, { today = new Date(), weeks = 4 }
2875
2880
  rows,
2876
2881
  facts: {
2877
2882
  weekStarts,
2883
+ currentWeek: {
2884
+ start: currentWeekStart,
2885
+ end: currentWeekEnd,
2886
+ observedThrough: todayIso,
2887
+ isPartial: todayIso < currentWeekEnd
2888
+ },
2878
2889
  weeklyTotals: weeklyTotals.map((value) => Math.round(value)),
2879
2890
  muscleCount: muscles.length,
2880
2891
  muscles
@@ -2904,6 +2915,7 @@ export function getRecentSessions(snapshot, { limit = 3, today = new Date(), rec
2904
2915
  warmupSetCount: warmupSetCount(exercise.sets ?? []),
2905
2916
  workingSetCount: sets.length,
2906
2917
  topSet: topCompletedSet(sets),
2918
+ recommendation: recommendationForExercise(session.recommendations, exercise.name),
2907
2919
  previousComparableSession: previousComparableExerciseSession(sortedSessions, session, exercise),
2908
2920
  sets
2909
2921
  };
@@ -3003,6 +3015,7 @@ export function getExerciseHistory(snapshot, { exercises = [], limit = 6, today
3003
3015
  warmupSetCount: warmupSetCount(exercise.sets ?? []),
3004
3016
  workingSetCount: completedSets.length,
3005
3017
  topSet: topCompletedSet(completedSets),
3018
+ recommendation: recommendationForExercise(session.recommendations, exercise.name),
3006
3019
  sets: completedSets
3007
3020
  });
3008
3021
  if (historyRows.length >= limit) break;
@@ -3806,7 +3819,12 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
3806
3819
 
3807
3820
  if (!excluded.has('score')) {
3808
3821
  const score = getIncrementScore(snapshot, { historyDays: Math.min(boundedWindowDays, 60) });
3809
- facts.score = Object.keys(score.facts ?? {}).length > 0 ? {
3822
+ // Gate on the score's own availability flag, not on whether facts is
3823
+ // non-empty. A real snapshot whose latest entry has a non-numeric score
3824
+ // comes back available:false with facts:{}, and an available snapshot
3825
+ // always carries available:true. Keying off Object.keys(facts) conflated
3826
+ // these two and could null out a legitimately scored athlete.
3827
+ facts.score = score.facts?.available === true ? {
3810
3828
  value: score.facts.score ?? null,
3811
3829
  band: score.facts.scoreBand ?? null,
3812
3830
  dayOverDayDelta: score.facts.dayOverDayDelta ?? null,
@@ -3825,6 +3843,7 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
3825
3843
  currentWeekSessions: volume.facts.currentWeekSessionCount ?? 0,
3826
3844
  previousWeek: volume.facts.previousWeekVolume ?? 0,
3827
3845
  previousWeekSessions: volume.facts.previousWeekSessionCount ?? 0,
3846
+ currentWeekIsPartial: volume.facts.currentWeekIsPartial ?? false,
3828
3847
  deltaPct: volume.facts.deltaPct ?? null
3829
3848
  };
3830
3849
  sourceIds.push(...volume.sourceIds);
@@ -3835,8 +3854,14 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
3835
3854
  if (!excluded.has('muscleVolume')) {
3836
3855
  const trendWeeks = Math.max(2, Math.min(5, Math.round(boundedWindowDays / 7)));
3837
3856
  const muscleTrend = getMuscleVolumeTrend(snapshot, { today: asOf, weeks: trendWeeks });
3857
+ const currentWeek = muscleTrend.facts.currentWeek;
3838
3858
  facts.muscleVolume = {
3839
3859
  weekStarts: muscleTrend.facts.weekStarts,
3860
+ currentWeek: currentWeek
3861
+ ? {
3862
+ isPartial: currentWeek.isPartial === true
3863
+ }
3864
+ : null,
3840
3865
  weeklyTotals: muscleTrend.facts.weeklyTotals,
3841
3866
  muscles: (muscleTrend.facts.muscles ?? []).slice(0, 6).map((row) => ({
3842
3867
  muscle: row.muscle,
@@ -3850,6 +3875,23 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
3850
3875
  missingDataFlags.push(...muscleTrend.missingDataFlags);
3851
3876
  }
3852
3877
 
3878
+ if (!excluded.has('bodyweight')) {
3879
+ const bw = getBodyWeightSnapshot(snapshot, { recentDays: Math.max(boundedWindowDays, 30), today: asOf });
3880
+ if (bw.facts?.latestBodyWeightKg != null) {
3881
+ facts.bodyweight = {
3882
+ latestKg: bw.facts.latestBodyWeightKg ?? null,
3883
+ latestDate: bw.facts.latestBodyWeightDate ?? null,
3884
+ trendKg: bw.facts.trendKg ?? null,
3885
+ trendDirection: bw.facts.trendDirection ?? null,
3886
+ avg7DayKg: bw.facts.average7DayBodyWeightKg ?? null,
3887
+ avg30DayKg: bw.facts.average30DayBodyWeightKg ?? null
3888
+ };
3889
+ }
3890
+ sourceIds.push(...bw.sourceIds);
3891
+ sourceTimestamps.push(bw.sourceTimestamp);
3892
+ missingDataFlags.push(...bw.missingDataFlags);
3893
+ }
3894
+
3853
3895
  if (!excluded.has('recovery')) {
3854
3896
  const readiness = getReadinessSnapshot(snapshot, {
3855
3897
  recentDays: Math.min(boundedWindowDays, 60),
@@ -4533,7 +4575,7 @@ export const COACH_READ_TOOL_SCHEMAS = Object.freeze({
4533
4575
  windowDays: { type: 'integer', minimum: 1, maximum: 365, default: 35 },
4534
4576
  exclude: {
4535
4577
  type: 'array',
4536
- items: { type: 'string', enum: ['recovery', 'records', 'notes', 'score', 'volume', 'muscleVolume'] },
4578
+ items: { type: 'string', enum: ['recovery', 'records', 'notes', 'score', 'volume', 'muscleVolume', 'bodyweight'] },
4537
4579
  default: []
4538
4580
  }
4539
4581
  },
@@ -28,6 +28,7 @@ import { extractPlanChangeset } from './plan-changeset.js';
28
28
 
29
29
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
30
30
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
31
+ const COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS = 20 * 60 * 60 * 1000;
31
32
  const WEEKLY_CHECKIN_COMPLETION_USER_TURNS = 3;
32
33
  const WEEKLY_CHECKIN_RECAP_LOCAL_HOUR = 16;
33
34
  const DEFAULT_RATE_LIMIT_RULES = {
@@ -70,6 +71,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
70
71
  'program-share-list': 60,
71
72
  'program-share-public': 120,
72
73
  'program-share-revoke': 30,
74
+ 'workout-share-create': 30,
75
+ 'workout-share-public': 120,
73
76
  'mobile-sync-bootstrap': 60,
74
77
  'mobile-sync-pull': 120,
75
78
  'mobile-sync-push': 60,
@@ -947,6 +950,38 @@ function coachObservationGenerationMetadata(result, sourceTrigger) {
947
950
  };
948
951
  }
949
952
 
953
+ function skippedCoachObservationGenerationMetadata(skippedReason, sourceTrigger) {
954
+ return {
955
+ attempted: false,
956
+ generated: 0,
957
+ persisted: 0,
958
+ skippedReason,
959
+ sourceTrigger: sourceTrigger ?? null
960
+ };
961
+ }
962
+
963
+ function recentDailyRefreshObservation(observations, now = new Date()) {
964
+ const threshold = now.getTime() - COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS;
965
+ return (Array.isArray(observations) ? observations : []).some((observation) => {
966
+ if (observation?.sourceTrigger !== 'daily_refresh') return false;
967
+ const generatedAt = new Date(observation.generatedAt ?? observation.windowEnd ?? 0).getTime();
968
+ return Number.isFinite(generatedAt) && generatedAt >= threshold;
969
+ });
970
+ }
971
+
972
+ async function coachObservationGenerationSkipReason({
973
+ account,
974
+ uploadResult,
975
+ sourceTrigger,
976
+ listCurrentCoachObservationsForAccount
977
+ }) {
978
+ if (sourceTrigger !== 'daily_refresh') return null;
979
+ if (Number(uploadResult?.inserted ?? 0) <= 0) return 'no_score_snapshot_change';
980
+ if (typeof listCurrentCoachObservationsForAccount !== 'function') return null;
981
+ const observations = await listCurrentCoachObservationsForAccount(account, { limit: 20 });
982
+ return recentDailyRefreshObservation(observations) ? 'recent_daily_refresh' : null;
983
+ }
984
+
950
985
  function normalizeAskCoachObservationFollowUp(value) {
951
986
  if (!value || typeof value !== 'object') return null;
952
987
  const id = String(value.id ?? '').trim();
@@ -1177,6 +1212,20 @@ function routeRequest(url, method) {
1177
1212
  }
1178
1213
  }
1179
1214
 
1215
+ if (pathname === '/cli/workout-share') {
1216
+ return { command: 'workout-share-create', options: {} };
1217
+ }
1218
+
1219
+ {
1220
+ const workoutSharePublicMatch = pathname.match(/^\/workout-share\/([^/]+)$/);
1221
+ if (workoutSharePublicMatch) {
1222
+ return {
1223
+ command: 'workout-share-public',
1224
+ options: { token: decodeURIComponent(workoutSharePublicMatch[1]) }
1225
+ };
1226
+ }
1227
+ }
1228
+
1180
1229
  {
1181
1230
  const programShareRevokeMatch = pathname.match(/^\/cli\/program-share\/([^/]+)\/revoke$/);
1182
1231
  if (programShareRevokeMatch) {
@@ -2316,6 +2365,8 @@ export function createSyncServiceRequestHandler({
2316
2365
  listProgramSharesForAccount = null,
2317
2366
  readPublicProgramShare = null,
2318
2367
  revokeProgramShareForAccount = null,
2368
+ createWorkoutShareForAccount = null,
2369
+ readPublicWorkoutShare = null,
2319
2370
  updateAnalysisConsentForAccount = null,
2320
2371
  updateDisplayNameForAccount = null,
2321
2372
  saveAskConversationForAccount = null,
@@ -3004,6 +3055,45 @@ export function createSyncServiceRequestHandler({
3004
3055
  }
3005
3056
  }
3006
3057
 
3058
+ if (route.command === 'workout-share-public') {
3059
+ if (request.method !== 'GET') {
3060
+ methodNotAllowed(response, 'Use GET for /workout-share/:token.');
3061
+ return;
3062
+ }
3063
+ if (!readPublicWorkoutShare) {
3064
+ methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
3065
+ return;
3066
+ }
3067
+ try {
3068
+ const shared = await readPublicWorkoutShare(route.options.token);
3069
+ if (shared.status === 'not_found') {
3070
+ notFound(response, 'Workout share not found.');
3071
+ return;
3072
+ }
3073
+ if (shared.status === 'revoked' || shared.status === 'expired') {
3074
+ json(response, 410, { error: 'Workout share is no longer available.' });
3075
+ return;
3076
+ }
3077
+ json(response, 200, {
3078
+ ok: true,
3079
+ token: route.options.token,
3080
+ version: shared.share.version,
3081
+ workoutName: shared.share.workoutName,
3082
+ workoutPayload: shared.share.workoutPayload,
3083
+ createdAt: shared.share.createdAt,
3084
+ expiresAt: shared.share.expiresAt
3085
+ });
3086
+ return;
3087
+ } catch (error) {
3088
+ if (error?.message === 'Invalid workout share token.') {
3089
+ badRequest(response, error.message);
3090
+ return;
3091
+ }
3092
+ internalError(response, error, onError);
3093
+ return;
3094
+ }
3095
+ }
3096
+
3007
3097
  if (route.command === 'google-mobile') {
3008
3098
  if (request.method !== 'POST') {
3009
3099
  methodNotAllowed(response, 'Use POST for /auth/google/mobile.');
@@ -3322,18 +3412,33 @@ export function createSyncServiceRequestHandler({
3322
3412
  const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
3323
3413
  if (generateCoachObservationsForAccount) {
3324
3414
  const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
3415
+ let skipReason = null;
3325
3416
  try {
3326
- const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
3327
- result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3417
+ skipReason = await coachObservationGenerationSkipReason({
3418
+ account,
3419
+ uploadResult: result,
3420
+ sourceTrigger,
3421
+ listCurrentCoachObservationsForAccount
3422
+ });
3328
3423
  } catch (error) {
3329
- console.error('Coach observation generation after score upload failed:', error.message);
3330
- result.coachObservations = {
3331
- attempted: true,
3332
- generated: 0,
3333
- persisted: 0,
3334
- skippedReason: 'generation_failed',
3335
- sourceTrigger
3336
- };
3424
+ console.error('Coach observation generation skip check failed:', error.message);
3425
+ }
3426
+ if (skipReason) {
3427
+ result.coachObservations = skippedCoachObservationGenerationMetadata(skipReason, sourceTrigger);
3428
+ } else {
3429
+ try {
3430
+ const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
3431
+ result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3432
+ } catch (error) {
3433
+ console.error('Coach observation generation after score upload failed:', error.message);
3434
+ result.coachObservations = {
3435
+ attempted: true,
3436
+ generated: 0,
3437
+ persisted: 0,
3438
+ skippedReason: 'generation_failed',
3439
+ sourceTrigger
3440
+ };
3441
+ }
3337
3442
  }
3338
3443
  }
3339
3444
  json(response, 200, result);
@@ -3604,6 +3709,53 @@ export function createSyncServiceRequestHandler({
3604
3709
  }
3605
3710
  }
3606
3711
 
3712
+ if (route.command === 'workout-share-create') {
3713
+ if (request.method !== 'POST') {
3714
+ methodNotAllowed(response, 'Use POST for /cli/workout-share.');
3715
+ return;
3716
+ }
3717
+ if (!createWorkoutShareForAccount) {
3718
+ methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
3719
+ return;
3720
+ }
3721
+ const account = connectedWriteAuthenticator
3722
+ ? await connectedWriteAuthenticator(requestToken)
3723
+ : null;
3724
+ if (!account) {
3725
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3726
+ return;
3727
+ }
3728
+ unauthorized(response, request);
3729
+ return;
3730
+ }
3731
+ try {
3732
+ const body = await readJsonBody(request);
3733
+ const workoutPayload = body.workoutPayload ?? body;
3734
+ const share = await createWorkoutShareForAccount(account, workoutPayload);
3735
+ json(response, 201, {
3736
+ ok: true,
3737
+ shareId: share.id,
3738
+ tokenHint: share.tokenHint,
3739
+ token: share.token,
3740
+ workoutName: share.workoutName,
3741
+ createdAt: share.createdAt,
3742
+ expiresAt: share.expiresAt,
3743
+ revokedAt: share.revokedAt,
3744
+ version: share.version,
3745
+ link: `${resolvedPublicOrigin}/workout-share/${share.token}`,
3746
+ deepLink: `incremnt://workout-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
3747
+ });
3748
+ return;
3749
+ } catch (error) {
3750
+ if (error?.message === 'Workout share payload is malformed.') {
3751
+ badRequest(response, error.message);
3752
+ return;
3753
+ }
3754
+ internalError(response, error, onError);
3755
+ return;
3756
+ }
3757
+ }
3758
+
3607
3759
  if (route.command === 'program-share-list') {
3608
3760
  if (request.method !== 'GET') {
3609
3761
  methodNotAllowed(response, 'Use GET for /cli/programs/:programId/shares.');
@@ -4024,45 +4176,8 @@ export function createSyncServiceRequestHandler({
4024
4176
  const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
4025
4177
  let refresh = null;
4026
4178
  if (route.options.refresh === 'morning_open') {
4027
- const writeAccount = writeAuthenticator
4028
- ? await writeAuthenticator(requestToken)
4029
- : requestToken === token
4030
- ? account
4031
- : null;
4032
- if (!writeAccount) {
4033
- if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4034
- return;
4035
- }
4036
- unauthorized(response, request);
4037
- return;
4038
- }
4039
4179
  const sourceTrigger = 'daily_refresh';
4040
- if (generateCoachObservationsForAccount) {
4041
- try {
4042
- const generationResult = await generateCoachObservationsForAccount(writeAccount, {
4043
- sourceTrigger,
4044
- refresh: route.options.refresh
4045
- });
4046
- refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
4047
- } catch (error) {
4048
- console.error('Coach observation generation on morning open failed:', error.message);
4049
- refresh = {
4050
- attempted: true,
4051
- generated: 0,
4052
- persisted: 0,
4053
- skippedReason: 'generation_failed',
4054
- sourceTrigger
4055
- };
4056
- }
4057
- } else {
4058
- refresh = {
4059
- attempted: false,
4060
- generated: 0,
4061
- persisted: 0,
4062
- skippedReason: 'generation_unavailable',
4063
- sourceTrigger
4064
- };
4065
- }
4180
+ refresh = skippedCoachObservationGenerationMetadata('morning_open_read_only', sourceTrigger);
4066
4181
  }
4067
4182
  const observations = await listCurrentCoachObservationsForAccount(account, {
4068
4183
  limit