incremnt 0.8.3 → 0.8.5

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/src/ask-coach.js CHANGED
@@ -278,7 +278,53 @@ function inferredSinceDate(question, today = new Date()) {
278
278
  return candidate > dateOnlyString(today) ? `${year - 1}-${monthPart}-01` : candidate;
279
279
  }
280
280
 
281
- function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
281
+ function deloadScheduleContextFromText(text) {
282
+ const raw = String(text ?? '');
283
+ if (!/\bd(?:e)?load\b/i.test(raw)) return null;
284
+ const match = raw.match(/\b(this|next|coming)\s+(?:training\s+)?week\b/i);
285
+ if (!match) return null;
286
+ return {
287
+ week: match[1].toLowerCase() === 'this' ? 'this' : 'next'
288
+ };
289
+ }
290
+
291
+ function latestDeloadScheduleContext(history = []) {
292
+ const messages = (Array.isArray(history) ? history : []).slice(-2);
293
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
294
+ const message = messages[index];
295
+ if (!['user', 'assistant'].includes(message?.role) || typeof message.content !== 'string') continue;
296
+ const context = deloadScheduleContextFromText(message.content);
297
+ if (context) return context;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ function isDeloadScheduleConfirmation(question) {
303
+ const text = String(question ?? '').trim().toLowerCase();
304
+ if (!text) return false;
305
+ if (/\b(no|don'?t|do not|cancel|undo|stop|never mind|nevermind)\b/i.test(text)) return false;
306
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
307
+ if (wordCount > 8) return false;
308
+ return /^(yes|yeah|yep|ok|okay|sure)\b/i.test(text) ||
309
+ /\b(do it|schedule it|set it|make it happen|schedule that|set that)\b/i.test(text);
310
+ }
311
+
312
+ function hasProgramHistoryLanguage(question, { previousRoute = null, isFollowUp = false } = {}) {
313
+ const text = String(question ?? '').toLowerCase();
314
+ const hasProgramLanguage = /\b(program|plan|routine|split)\b/i.test(text);
315
+ if (/\bwhat changed\b[\s\S]{0,80}\b(program|plan|routine|split)\b/i.test(text) ||
316
+ /\b(program|plan|routine|split)\b[\s\S]{0,80}\bwhat changed\b/i.test(text) ||
317
+ /\bwhy\b[\s\S]{0,80}\b(program|plan|routine|split)\b[\s\S]{0,80}\bchang/i.test(text)) {
318
+ return true;
319
+ }
320
+
321
+ const restoreLanguage = /\b(undo that|undo it|revert that|revert it|restore that|restore it|change it back|previous version|last change)\b/i.test(text);
322
+ if (!restoreLanguage) return false;
323
+ if (hasProgramLanguage) return true;
324
+ return isFollowUp && ['program_history', 'program_progress', 'program_schedule_action'].includes(previousRoute);
325
+ }
326
+
327
+ function routeAskQuestion(snapshot, question, { today = new Date(), previousRoute = null, isFollowUp = false } = {}) {
282
328
  const normalizedQuestion = normalizeExerciseName(question ?? '');
283
329
  const namedExercises = namedExercisesFromQuestion(snapshot, question);
284
330
  const sessionReference = referencedSessionFromQuestion(snapshot, question);
@@ -287,6 +333,10 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
287
333
  const progressLanguage = /\b(progress|progressing|improve|improved|improvement|better|stronger|moved|moving|stalled|flat|since)\b/i.test(question ?? '');
288
334
  const profileLanguage = /\b(know about me|about me as a lifter|me as a lifter|lifter profile|training profile)\b/i.test(question ?? '');
289
335
  const programLanguage = /\b(program|plan|split|routine|full gym|ppl|upper lower)\b/i.test(question ?? '');
336
+ const deloadWord = 'd(?:e)?load';
337
+ const deloadScheduleContext = deloadScheduleContextFromText(question);
338
+ const deloadScheduleLanguage = new RegExp(`\\b(?:make|schedule|set|turn|change|adjust)\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b[\\s\\S]{0,120}\\b${deloadWord}\\b`, 'i').test(question ?? '') ||
339
+ new RegExp(`\\b(?:make|schedule|set|turn|change|adjust)\\b[\\s\\S]{0,120}\\b${deloadWord}\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b`, 'i').test(question ?? '');
290
340
  const windowDays = inferredRelativeWindowDays(question);
291
341
  const reviewVerb = /\b(doing|going|look(?:s|ing|ed)?|progress\w*|train(?:s|ing)?|workouts?|fitness)\b/i.test(question ?? '');
292
342
  const explicitReview = /\b(on (?:the )?(?:right )?track|right track|making (?:good )?progress|overall progress|review (?:my|the)\b|check (?:out\s+)?my\b|how(?:'?s| is| has| have)\s+(?:my|the)\s+(?:training|progress|fitness))\b/i.test(question ?? '');
@@ -303,6 +353,13 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
303
353
  /\b(pr|prs|record|records|max|maxes|1rm|one rep max|one-rep max|strongest)\b/i.test(question ?? '') ||
304
354
  /\b(next|tomorrow|up next|coming session|do next|what should i do)\b/i.test(question ?? '');
305
355
 
356
+ if (deloadScheduleLanguage) {
357
+ return { route: 'program_schedule_action', namedExercises, deloadScheduleContext };
358
+ }
359
+ if (hasProgramHistoryLanguage(question, { previousRoute, isFollowUp })) {
360
+ return { route: 'program_history', namedExercises, deloadScheduleContext };
361
+ }
362
+
306
363
  // Broad "how am I doing / on the right track / last N weeks" reviews fan out to
307
364
  // sessions + volume + records + body weight rather than a single narrow route.
308
365
  // Requires an explicit review cue, or a relative window paired with a review verb.
@@ -311,16 +368,16 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
311
368
  (!narrowSingleTopic || compositeReviewCue) &&
312
369
  broadReviewIntent
313
370
  ) {
314
- return { route: 'progress_review', namedExercises, since };
371
+ return { route: 'progress_review', namedExercises, since, deloadScheduleContext };
315
372
  }
316
373
  if (/\b(body ?weight|weigh|weight trends?|current weight|my weight)\b/i.test(question ?? '')) {
317
- return { route: 'body_weight', namedExercises };
374
+ return { route: 'body_weight', namedExercises, deloadScheduleContext };
318
375
  }
319
376
  if (profileLanguage) {
320
- return { route: 'training_profile', namedExercises, since };
377
+ return { route: 'training_profile', namedExercises, since, deloadScheduleContext };
321
378
  }
322
379
  if (programLanguage && progressLanguage) {
323
- return { route: 'program_progress', namedExercises, since };
380
+ return { route: 'program_progress', namedExercises, since, deloadScheduleContext };
324
381
  }
325
382
  if (since && progressLanguage) {
326
383
  return { route: 'exercise_progress_summary', namedExercises, since };
@@ -374,6 +431,9 @@ function isTerseFollowUpQuestion(question) {
374
431
 
375
432
  function requestedActionForRoute(route, question, { isFollowUp = false, carriedPreviousTopic = false } = {}) {
376
433
  const text = String(question ?? '').toLowerCase();
434
+ if (route === 'program_schedule_action') return 'schedule_program_action';
435
+ if (route === 'program_history' && /\b(undo|revert|restore|change it back|previous version)\b/.test(text)) return 'explain_restore_status';
436
+ if (route === 'program_history') return 'explain_program_history';
377
437
  if (/\b(why|how come)\b/.test(text)) return 'explain_cause';
378
438
  if (/\b(build|create|make|generate|draft|rewrite|revise|update)\b/.test(text)) return 'draft_plan';
379
439
  if (/\badjust\b/.test(text) && /\b(program|plan|split|routine)\b/.test(text)) return 'draft_plan';
@@ -388,6 +448,7 @@ function requestedActionForRoute(route, question, { isFollowUp = false, carriedP
388
448
  exercise_progress: 'explain_exercise',
389
449
  volume: 'explain_volume',
390
450
  next_session: 'recommend_next_session',
451
+ program_history: 'explain_program_history',
391
452
  recovery: 'explain_recovery',
392
453
  score: 'explain_score',
393
454
  records: 'summarize_records',
@@ -406,6 +467,7 @@ export const ASK_RESPONSE_PROFILES = Object.freeze({
406
467
  });
407
468
 
408
469
  function responseProfileForAskIntent(route, requestedAction, question) {
470
+ if (route === 'program_schedule_action' || requestedAction === 'schedule_program_action') return ASK_RESPONSE_PROFILES.structured;
409
471
  if (route === 'program_design' || requestedAction === 'draft_plan') return ASK_RESPONSE_PROFILES.structured;
410
472
  const text = String(question ?? '').toLowerCase();
411
473
  if (
@@ -445,23 +507,36 @@ function ambiguityFlagsForIntent({ route, namedExercises, question, isFollowUp,
445
507
  return flags;
446
508
  }
447
509
 
448
- function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = null, today = new Date() } = {}) {
449
- const current = routeAskQuestion(snapshot, question, { today });
510
+ function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = null, previousDeloadScheduleContext = null, today = new Date() } = {}) {
450
511
  const previous = previousIntent
451
512
  ? {
452
513
  route: previousIntent.route,
453
514
  since: previousIntent.timeframe?.since ?? null,
454
515
  namedExercises: previousIntent.entities?.exercises ?? [],
455
516
  sessionLabel: previousIntent.entities?.sessionLabel ?? null,
456
- sessionReference: previousIntent.entities?.sessionReference ?? null
517
+ sessionReference: previousIntent.entities?.sessionReference ?? null,
518
+ deloadScheduleContext: previousIntent.deloadScheduleContext ?? null
457
519
  }
458
520
  : null;
459
- const isFollowUp = Boolean(previous && isTerseFollowUpQuestion(question));
521
+ const isFollowUp = Boolean((previous || previousDeloadScheduleContext) && isTerseFollowUpQuestion(question));
522
+ const current = routeAskQuestion(snapshot, question, {
523
+ today,
524
+ previousRoute: previous?.route ?? null,
525
+ isFollowUp
526
+ });
527
+ const currentDeloadScheduleContext = current.deloadScheduleContext ?? deloadScheduleContextFromText(question);
528
+ const carriedDeloadScheduleContext = currentDeloadScheduleContext ?? previousDeloadScheduleContext ?? previous?.deloadScheduleContext ?? null;
529
+ const isDeloadScheduleFollowUp = current.route !== 'program_schedule_action' &&
530
+ Boolean(carriedDeloadScheduleContext) &&
531
+ isDeloadScheduleConfirmation(question);
460
532
  let route = current.route;
461
533
  let since = current.since ?? null;
462
534
  let carriedPreviousTopic = false;
463
535
 
464
- if (
536
+ if (isDeloadScheduleFollowUp) {
537
+ route = 'program_schedule_action';
538
+ since = null;
539
+ } else if (
465
540
  isFollowUp &&
466
541
  previous?.route &&
467
542
  current.namedExercises.length > 0 &&
@@ -508,6 +583,9 @@ function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = nu
508
583
  responseProfile,
509
584
  isFollowUp,
510
585
  previousRoute: previous?.route ?? null,
586
+ deloadScheduleContext: route === 'program_schedule_action'
587
+ ? carriedDeloadScheduleContext
588
+ : currentDeloadScheduleContext,
511
589
  ambiguityFlags: ambiguityFlagsForIntent({
512
590
  route,
513
591
  namedExercises,
@@ -523,7 +601,11 @@ export function classifyAskIntent(snapshot, question, { history = [], today = ne
523
601
  for (const previousQuestion of historyUserQuestions(history)) {
524
602
  previousIntent = classifyAskIntentWithPrevious(snapshot, previousQuestion, { previousIntent, today });
525
603
  }
526
- return classifyAskIntentWithPrevious(snapshot, question, { previousIntent, today });
604
+ return classifyAskIntentWithPrevious(snapshot, question, {
605
+ previousIntent,
606
+ previousDeloadScheduleContext: latestDeloadScheduleContext(history),
607
+ today
608
+ });
527
609
  }
528
610
 
529
611
  function pushAskContextHeader(lines, snapshot, today = new Date()) {
@@ -539,18 +621,19 @@ function pushAskContextHeader(lines, snapshot, today = new Date()) {
539
621
  const ASK_FACT_KIND_BY_ROUTE = Object.freeze({
540
622
  general: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
541
623
  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'],
624
+ exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
625
+ exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
626
+ program_progress: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
627
+ program_schedule_action: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
545
628
  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'],
629
+ program_design: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
630
+ next_session: ['constraint', 'injury', 'preference', 'goal_signal', 'tone'],
631
+ recent_session: ['injury', 'constraint', 'goal_signal', 'tone'],
549
632
  recovery: ['injury', 'constraint', 'tone'],
550
- body_weight: ['goal_signal'],
551
- volume: ['goal_signal', 'constraint'],
552
- records: ['goal_signal'],
553
- score: ['goal_signal', 'constraint']
633
+ body_weight: ['goal_signal', 'tone'],
634
+ volume: ['goal_signal', 'constraint', 'tone'],
635
+ records: ['goal_signal', 'tone'],
636
+ score: ['goal_signal', 'constraint', 'tone']
554
637
  });
555
638
 
556
639
  function normalizeCoachFactForContext(row) {
@@ -611,6 +694,13 @@ function appendCoachFactsContext(lines, facts) {
611
694
  .join(', ');
612
695
  lines.push(` [${fact.kind}] ${fact.fact}${provenance ? ` (${provenance})` : ''}`);
613
696
  }
697
+ const toneFacts = facts
698
+ .filter((fact) => fact.kind === 'tone')
699
+ .map((fact) => fact.fact)
700
+ .filter(Boolean);
701
+ if (toneFacts.length > 0) {
702
+ lines.push(`Answer style preference to carry explicitly: ${toneFacts.join(' ')}`);
703
+ }
614
704
  return facts.map((fact) => fact.id).filter(Boolean);
615
705
  }
616
706
 
@@ -643,6 +733,8 @@ const ASK_ROUTE_REQUIRED_TOOLS = Object.freeze({
643
733
  exercise_progress: ['get_exercise_history'],
644
734
  exercise_progress_summary: ['get_exercise_progress_summary'],
645
735
  program_progress: ['get_program_progress', 'get_exercise_progress_summary', 'get_cycle_progression_summary'],
736
+ program_history: ['get_program_change_history'],
737
+ program_schedule_action: ['get_program_progress', 'get_recent_sessions', 'get_readiness_snapshot'],
646
738
  training_profile: ['get_training_profile'],
647
739
  records: ['get_records'],
648
740
  recent_session: ['get_recent_sessions'],
@@ -857,17 +949,29 @@ function appendReadinessSummary(lines, readiness) {
857
949
  // Per-route prose builders that compose tool results into the routed
858
950
  // Ask Coach context, attaching provenance for each section.
859
951
 
952
+ function appendWeeklyVolumeEvidence(lines, weeklyVolume) {
953
+ const facts = weeklyVolume?.facts ?? {};
954
+ const isPartial = facts.currentWeekIsPartial === true;
955
+ const currentLabel = isPartial ? 'Week-to-date strength volume' : 'This week strength volume';
956
+ const previousLabel = 'Previous full week strength volume';
957
+ lines.push(`${currentLabel}: ${facts.currentWeekVolume} kg across ${facts.currentWeekSessionCount} session${facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
958
+ lines.push(`${previousLabel}: ${facts.previousWeekVolume} kg across ${facts.previousWeekSessionCount} session${facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
959
+ if (facts.deltaPct != null) {
960
+ const prefix = facts.deltaPct >= 0 ? '+' : '';
961
+ const comparison = isPartial
962
+ ? 'Week-to-date versus previous full week strength volume'
963
+ : 'Week-over-week strength volume change';
964
+ lines.push(`${comparison}: ${prefix}${facts.deltaPct}%.`);
965
+ }
966
+ }
967
+
860
968
  function buildVolumeAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
861
969
  const lines = [];
862
970
  const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
863
971
  pushAskContextHeader(lines, snapshot, today);
864
972
 
865
973
  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
- }
974
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
871
975
  const thisWeekRows = weeklyVolume.rows.filter((row) => row.week === 'current');
872
976
  if (thisWeekRows.length > 0) {
873
977
  lines.push('This week sessions:');
@@ -911,10 +1015,48 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
911
1015
  }
912
1016
 
913
1017
  function formattedCompletedSets(sets = []) {
914
- return sets.map((set) => {
1018
+ const groups = [];
1019
+ for (const set of sets) {
1020
+ const weight = Number(set.weight) || 0;
1021
+ const label = weight > 0 ? `${formatCompactWeight(weight)}kg` : 'BW';
1022
+ const previous = groups.at(-1);
1023
+ if (previous?.label === label) {
1024
+ previous.reps.push(set.reps);
1025
+ } else {
1026
+ groups.push({ label, reps: [set.reps] });
1027
+ }
1028
+ }
1029
+ return groups.map((group) => `${group.label}: ${group.reps.join('/')}`).join(', ');
1030
+ }
1031
+
1032
+ function formattedCompletedSetShorthand(sets = []) {
1033
+ const groups = [];
1034
+ for (const set of sets) {
915
1035
  const weight = Number(set.weight) || 0;
916
- return weight > 0 ? `${weight.toFixed(1)}x${set.reps}` : `BWx${set.reps}`;
917
- }).join(', ');
1036
+ const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
1037
+ const previous = groups.at(-1);
1038
+ if (previous?.label === label) {
1039
+ previous.reps.push(set.reps);
1040
+ } else {
1041
+ groups.push({ label, reps: [set.reps] });
1042
+ }
1043
+ }
1044
+ return groups.map((group) => `${group.label}x${group.reps.join('/')}`).join(', ');
1045
+ }
1046
+
1047
+ function formattedSetShorthandList(sets = []) {
1048
+ return sets
1049
+ .map((set) => {
1050
+ const weight = Number(set.weight) || 0;
1051
+ const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
1052
+ return `${label}x${set.reps}`;
1053
+ })
1054
+ .join(', ');
1055
+ }
1056
+
1057
+ function formatCompactWeight(weight) {
1058
+ const rounded = Math.round(Number(weight) * 10) / 10;
1059
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
918
1060
  }
919
1061
 
920
1062
  function formatRepDelta(delta) {
@@ -971,6 +1113,11 @@ function formatComparableSetDelta(exercise) {
971
1113
  if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
972
1114
  details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
973
1115
  }
1116
+ const currentSetList = formattedSetShorthandList(currentSets);
1117
+ const previousSetList = formattedSetShorthandList(previousSets);
1118
+ if (currentSetList && previousSetList) {
1119
+ details.push(`current sets ${currentSetList}; previous sets ${previousSetList}`);
1120
+ }
974
1121
  if (regressionFlag) {
975
1122
  details.push('regression flag: reps dropped sharply despite the load/set context');
976
1123
  }
@@ -1061,6 +1208,83 @@ function buildNextSessionAskContext(snapshot, { exclude = new Set(), today = new
1061
1208
  return { context: lines.join('\n'), sections, tools: [nextSession], provenance: [coachToolProvenance('next_session_plan', nextSession)] };
1062
1209
  }
1063
1210
 
1211
+ function mondayWeekStartDateOnly(today = new Date(), weekOffset = 0) {
1212
+ const base = new Date(`${dateOnlyString(today)}T00:00:00.000Z`);
1213
+ const day = base.getUTCDay();
1214
+ const daysSinceMonday = (day + 6) % 7;
1215
+ base.setUTCDate(base.getUTCDate() - daysSinceMonday + weekOffset * 7);
1216
+ return base.toISOString().slice(0, 10);
1217
+ }
1218
+
1219
+ function deloadScheduleStartDate(question, today = new Date(), scheduleContext = null) {
1220
+ if (/\bthis\s+(?:training\s+)?week\b/i.test(question ?? '')) {
1221
+ return mondayWeekStartDateOnly(today, 0);
1222
+ }
1223
+ if (/\b(?:next|coming)\s+(?:training\s+)?week\b/i.test(question ?? '')) {
1224
+ return mondayWeekStartDateOnly(today, 1);
1225
+ }
1226
+ return scheduleContext?.week === 'this'
1227
+ ? mondayWeekStartDateOnly(today, 0)
1228
+ : mondayWeekStartDateOnly(today, 1);
1229
+ }
1230
+
1231
+ function appendProgramScheduleActionRequest(lines, { startDate, hasActiveProgram }) {
1232
+ lines.push('');
1233
+ lines.push('Program schedule action request:');
1234
+ if (!hasActiveProgram) {
1235
+ lines.push(' No active program is available. Do not append a <program_schedule_action> block.');
1236
+ return;
1237
+ }
1238
+ lines.push(' The user wants to schedule a one-week whole-program deload. This is not a new program draft and not an exercise-specific plan changeset.');
1239
+ lines.push(' If the request is clear, keep prose to 1-2 short sentences and append exactly one trailing <program_schedule_action>{JSON}</program_schedule_action>.');
1240
+ lines.push(` Use this exact JSON shape: {"action":"schedule_deload_week","startDate":"${startDate}","durationWeeks":1,"rationale":"..."}.`);
1241
+ lines.push(' The app will compute the weight changes when the week starts. Do not include weights, reps, set counts, deltas, targets, or any extra JSON keys.');
1242
+ lines.push(' Do not append a <program_draft> or <plan_changeset> block.');
1243
+ }
1244
+
1245
+ function buildProgramScheduleActionAskContext(snapshot, question, { exclude = new Set(), today = new Date(), scheduleContext = null } = {}) {
1246
+ const lines = [];
1247
+ const programProgress = executeCoachReadTool(snapshot, 'get_program_progress', { since: null, today, limitExercises: 10 });
1248
+ const recentSessions = executeCoachReadTool(snapshot, 'get_recent_sessions', { limit: 5, today });
1249
+ const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', { today });
1250
+ const program = activeProgram(snapshot);
1251
+ const startDate = deloadScheduleStartDate(question, today, scheduleContext);
1252
+
1253
+ pushAskContextHeader(lines, snapshot, today);
1254
+ lines.push('');
1255
+ lines.push(`Program schedule target: ${program?.name ?? 'No active program'}.`);
1256
+ const trainingLoad = programProgress.facts?.trainingLoad;
1257
+ if (trainingLoad) {
1258
+ const readinessBand = trainingLoad.readiness?.readinessBand ? `, readiness ${trainingLoad.readiness.readinessBand}` : '';
1259
+ lines.push(`Training-load signal: status ${trainingLoad.status ?? 'unknown'}${readinessBand}.`);
1260
+ }
1261
+ if (readiness.facts?.readinessBand) {
1262
+ lines.push(`Readiness signal: ${readiness.facts.readinessBand}.`);
1263
+ }
1264
+ appendActiveProgramScheduleContext(lines, snapshot);
1265
+ if (recentSessions.rows?.length > 0) {
1266
+ lines.push('');
1267
+ lines.push('Recent sessions:');
1268
+ for (const row of recentSessions.rows.slice(0, 5)) {
1269
+ const title = row.title ?? row.dayName ?? row.programDayTitle ?? 'Workout';
1270
+ lines.push(` ${row.date ?? 'unknown date'} - ${title}`);
1271
+ }
1272
+ }
1273
+ appendProgramScheduleActionRequest(lines, { startDate, hasActiveProgram: Boolean(program) });
1274
+ appendExcludeNote(lines, exclude);
1275
+ return {
1276
+ context: lines.join('\n'),
1277
+ sections: ['header', 'program_schedule_action', 'current_program_schedule', 'recent_sessions'],
1278
+ programScheduleActionStartDate: startDate,
1279
+ tools: [programProgress, recentSessions, readiness],
1280
+ provenance: [
1281
+ coachToolProvenance('program_schedule_program_progress', programProgress),
1282
+ coachToolProvenance('program_schedule_recent_sessions', recentSessions),
1283
+ coachToolProvenance('program_schedule_readiness', readiness)
1284
+ ]
1285
+ };
1286
+ }
1287
+
1064
1288
  function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = new Set(), today = new Date() } = {}) {
1065
1289
  const lines = [];
1066
1290
  const exerciseHistoryTool = executeCoachReadTool(snapshot, 'get_exercise_history', { exercises: namedExercises, limit: 6, today });
@@ -1079,7 +1303,9 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
1079
1303
  for (const row of exerciseHistoryTool.rows) {
1080
1304
  const comparison = formatTopSetComparison(row);
1081
1305
  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}`);
1306
+ const shorthand = formattedCompletedSetShorthand(row.sets);
1307
+ lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${shorthand ? ` (compact: ${shorthand})` : ''}${comparison ? `; ${comparison}` : ''}${warmups}`);
1308
+ if (row.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(row.recommendation)}`);
1083
1309
  }
1084
1310
  appendExerciseHistoryNotes(lines, exerciseHistoryTool.rows);
1085
1311
  }
@@ -1091,9 +1317,21 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
1091
1317
 
1092
1318
  function formatProgressPoint(point) {
1093
1319
  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})`;
1320
+ const load = Number(point.weight) > 0 ? `${formatCompactWeight(point.weight)}x${point.reps}` : `BWx${point.reps}`;
1321
+ return `${point.date}: ${load}`;
1322
+ }
1323
+
1324
+ function progressVerdict(row) {
1325
+ const bestDelta = Number(row?.bestDeltaFromFirst);
1326
+ const latestDelta = Number(row?.latestDeltaFromFirst);
1327
+ const latestFromBest = Number(row?.latestDeltaFromBest);
1328
+ if (Number.isFinite(bestDelta) && bestDelta > 0 && Number.isFinite(latestFromBest) && latestFromBest < 0) {
1329
+ return 'peaked early, then dipped';
1330
+ }
1331
+ if (Number.isFinite(latestDelta) && latestDelta > 0) return 'latest is up from first';
1332
+ if (Number.isFinite(latestDelta) && latestDelta < 0) return 'latest is down from first';
1333
+ if (Number.isFinite(latestDelta)) return 'latest matches first';
1334
+ return null;
1097
1335
  }
1098
1336
 
1099
1337
  function appendProgressSummaryRows(lines, rows = []) {
@@ -1111,7 +1349,8 @@ function appendProgressSummaryRows(lines, rows = []) {
1111
1349
  const latestDropFromBest = row.latestDeltaFromBest == null
1112
1350
  ? ''
1113
1351
  : `, 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}.`);
1352
+ const verdict = progressVerdict(row);
1353
+ lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}${verdict ? `; tracking verdict: ${verdict}` : ''}.`);
1115
1354
  }
1116
1355
  }
1117
1356
 
@@ -1209,6 +1448,37 @@ function buildProgramProgressAskContext(snapshot, { exclude = new Set(), since =
1209
1448
  };
1210
1449
  }
1211
1450
 
1451
+ function buildProgramHistoryAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
1452
+ const lines = [];
1453
+ const history = executeCoachReadTool(snapshot, 'get_program_change_history', { limit: 20 });
1454
+ pushAskContextHeader(lines, snapshot, today);
1455
+ lines.push('');
1456
+ lines.push(`Program change history: ${history.facts?.programName ?? 'No active program'}.`);
1457
+ if (history.rows.length === 0) {
1458
+ lines.push(' No durable program change records are available yet.');
1459
+ } else {
1460
+ for (const change of history.rows.slice(0, 10)) {
1461
+ const when = change.createdAt ? String(change.createdAt).slice(0, 10) : 'unknown date';
1462
+ const source = change.source ? `, source ${change.source}` : '';
1463
+ const status = change.status ? `, status ${change.status}` : '';
1464
+ lines.push(` ${when}: ${change.summary ?? change.kind ?? 'Program changed'} (${change.kind ?? 'unknown'}${source}${status}, id ${change.id ?? 'unknown'}).`);
1465
+ const exercises = change.affectedExercises?.length ? `exercises ${change.affectedExercises.slice(0, 4).join(', ')}` : null;
1466
+ const fields = change.affectedFields?.length ? `fields ${change.affectedFields.slice(0, 5).join(', ')}` : null;
1467
+ if (exercises || fields) lines.push(` Affected: ${[exercises, fields].filter(Boolean).join('; ')}.`);
1468
+ if (change.rationale) lines.push(` Rationale: ${change.rationale}`);
1469
+ }
1470
+ }
1471
+ lines.push('');
1472
+ lines.push('Restore status: automatic restore is not available in this slice. You may identify the likely change to restore, but do not claim the app restored or changed the program.');
1473
+ appendExcludeNote(lines, exclude);
1474
+ return {
1475
+ context: lines.join('\n'),
1476
+ sections: ['header', 'program_change_history'],
1477
+ tools: [history],
1478
+ provenance: [coachToolProvenance('program_change_history', history)]
1479
+ };
1480
+ }
1481
+
1212
1482
  function buildTrainingProfileAskContext(snapshot, { exclude = new Set(), since = null, today = new Date() } = {}) {
1213
1483
  const lines = [];
1214
1484
  const trainingProfile = executeCoachReadTool(snapshot, 'get_training_profile', { since, today });
@@ -1345,9 +1615,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1345
1615
  exclude = new Set(),
1346
1616
  today = new Date(),
1347
1617
  namedExercises = [],
1348
- existingSections = []
1618
+ existingSections = [],
1619
+ omitSections = []
1349
1620
  } = {}) {
1350
1621
  const sections = new Set(existingSections);
1622
+ const omitted = new Set(omitSections);
1351
1623
  const note = buildExcludeNote(exclude);
1352
1624
  const noteAtEnd = note && lines.at(-1) === note;
1353
1625
  if (noteAtEnd) {
@@ -1364,24 +1636,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1364
1636
  addedSections.push(section);
1365
1637
  };
1366
1638
 
1367
- if (!sections.has('increment_score')) {
1639
+ if (!sections.has('increment_score') && !omitted.has('increment_score')) {
1368
1640
  const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
1369
1641
  appendIncrementScoreEvidence(lines, incrementScore);
1370
1642
  addTool('increment_score', incrementScore);
1371
1643
  }
1372
1644
 
1373
- if (!sections.has('weekly_volume')) {
1645
+ if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
1374
1646
  const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
1375
1647
  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
- }
1648
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1381
1649
  addTool('weekly_volume', weeklyVolume);
1382
1650
  }
1383
1651
 
1384
- if (!sections.has('records')) {
1652
+ if (!sections.has('records') && !omitted.has('records')) {
1385
1653
  const records = executeCoachReadTool(snapshot, 'get_records', {
1386
1654
  exercises: namedExercises,
1387
1655
  limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
@@ -1391,7 +1659,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1391
1659
  addTool('records', records);
1392
1660
  }
1393
1661
 
1394
- if (!sections.has('body_weight')) {
1662
+ if (!sections.has('body_weight') && !omitted.has('body_weight')) {
1395
1663
  const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
1396
1664
  recentDays: 30,
1397
1665
  exclude: [...exclude],
@@ -1401,7 +1669,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1401
1669
  addTool('body_weight', bodyWeight);
1402
1670
  }
1403
1671
 
1404
- if (!sections.has('readiness')) {
1672
+ if (!sections.has('readiness') && !omitted.has('readiness')) {
1405
1673
  const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
1406
1674
  recentDays: 14,
1407
1675
  exclude: [...exclude],
@@ -1411,7 +1679,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1411
1679
  addTool('readiness', readiness);
1412
1680
  }
1413
1681
 
1414
- if (!sections.has('goal_status')) {
1682
+ if (!sections.has('goal_status') && !omitted.has('goal_status')) {
1415
1683
  const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
1416
1684
  appendGoalStatusEvidence(lines, goalStatus);
1417
1685
  addTool('goal_status', goalStatus);
@@ -1468,6 +1736,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
1468
1736
  const setsStr = formattedCompletedSets(exercise.sets);
1469
1737
  const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
1470
1738
  if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
1739
+ if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
1471
1740
  const setDelta = formatComparableSetDelta(exercise);
1472
1741
  if (setDelta) lines.push(` ${setDelta}`);
1473
1742
  }
@@ -1636,11 +1905,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
1636
1905
 
1637
1906
  // Weekly volume with week-over-week direction.
1638
1907
  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
- }
1908
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1644
1909
 
1645
1910
  // Per-session top sets so the model can see real progression, not just names.
1646
1911
  const recent = recentSessions.rows.slice().reverse();
@@ -1965,10 +2230,13 @@ function confidenceBand(intentConfidence, missingDataFlags = []) {
1965
2230
  return 'high';
1966
2231
  }
1967
2232
 
1968
- function recommendedActionsForAsk(route, requestedAction, programDraft) {
2233
+ function recommendedActionsForAsk(route, requestedAction, programDraft, programScheduleAction = null) {
1969
2234
  if (programDraft) {
1970
2235
  return [{ id: 'review-program-draft', label: 'Review drafted plan', kind: 'program_draft' }];
1971
2236
  }
2237
+ if (programScheduleAction) {
2238
+ return [{ id: 'review-program-schedule-action', label: 'Review scheduled deload', kind: 'program_schedule_action' }];
2239
+ }
1972
2240
  if (requestedAction === 'draft_plan') {
1973
2241
  return [{ id: 'ask-for-plan-draft', label: 'Ask for a plan draft', kind: 'follow_up' }];
1974
2242
  }
@@ -2048,10 +2316,11 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
2048
2316
  volume: ['What should I do next session?', 'Is this too much weekly volume?'],
2049
2317
  next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
2050
2318
  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?'],
2319
+ recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
2052
2320
  body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
2053
2321
  score: ['What is pulling my score down?', 'What should I focus on this week?'],
2054
2322
  program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
2323
+ program_history: ['What changed most recently?', 'Why did that change happen?', 'Can that be restored later?'],
2055
2324
  program_design: ['Make this plan more conservative.', 'Explain the main changes.']
2056
2325
  };
2057
2326
  let candidates;
@@ -2106,7 +2375,7 @@ export function sanitizeAskAnswerVerificationReceipt(value) {
2106
2375
  return Object.keys(result).length > 0 ? result : null;
2107
2376
  }
2108
2377
 
2109
- export function buildAskStructuredResponse(answer, routingMetadata = {}, { programDraft = null, planChangeset = null, question = '' } = {}) {
2378
+ export function buildAskStructuredResponse(answer, routingMetadata = {}, { programDraft = null, planChangeset = null, programScheduleAction = null, question = '' } = {}) {
2110
2379
  const contextBundle = routingMetadata.contextBundle ?? {};
2111
2380
  const intent = routingMetadata.intent ?? contextBundle.intent ?? {};
2112
2381
  const answerVerification = sanitizeAskAnswerVerificationReceipt(routingMetadata.answerVerification);
@@ -2120,12 +2389,13 @@ export function buildAskStructuredResponse(answer, routingMetadata = {}, { progr
2120
2389
  answer,
2121
2390
  confidence,
2122
2391
  evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? [], routingMetadata.tools ?? []),
2123
- recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft),
2392
+ recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft, programScheduleAction),
2124
2393
  followUpSuggestions: followUpSuggestionsForAsk(intent.route ?? routingMetadata.route, intent, { question, missingDataFlags }),
2125
2394
  limitations: missingDataFlags.filter((flag) => !HIDDEN_LIMITATION_FLAGS.has(flag)).map(limitationText),
2126
2395
  answerVerification,
2127
2396
  programDraft: programDraft ?? null,
2128
- planChangeset: planChangeset ?? null
2397
+ planChangeset: planChangeset ?? null,
2398
+ programScheduleAction: programScheduleAction ?? null
2129
2399
  };
2130
2400
  }
2131
2401
 
@@ -2229,6 +2499,7 @@ function appendAskAnswerContract(lines, {
2229
2499
  namedExerciseLabels = [],
2230
2500
  builtTools = [],
2231
2501
  sessionObservationComparisons = [],
2502
+ includedFacts = [],
2232
2503
  question = ''
2233
2504
  } = {}) {
2234
2505
  const note = buildExcludeNote(new Set());
@@ -2241,6 +2512,15 @@ function appendAskAnswerContract(lines, {
2241
2512
  const contract = [];
2242
2513
  const fullExerciseNames = namedExerciseLabels.filter(Boolean);
2243
2514
  const text = String(question ?? '').toLowerCase();
2515
+ const toneFacts = includedFacts
2516
+ .filter((fact) => fact.kind === 'tone')
2517
+ .map((fact) => fact.fact)
2518
+ .filter(Boolean);
2519
+
2520
+ if (fullExerciseNames.length > 0) {
2521
+ contract.push('Answer contract: named exercise identity.');
2522
+ contract.push(` Use the exact exercise name(s): ${fullExerciseNames.join(', ')}.`);
2523
+ }
2244
2524
 
2245
2525
  if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
2246
2526
  contract.push('Answer contract: defensive decision.');
@@ -2248,12 +2528,17 @@ function appendAskAnswerContract(lines, {
2248
2528
  contract.push(' Name the relevant exercise exactly as written in the evidence.');
2249
2529
  contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
2250
2530
  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.');
2531
+ 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
2532
  if (fullExerciseNames.length > 0) {
2253
2533
  contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
2254
2534
  }
2255
2535
  }
2256
2536
 
2537
+ if (toneFacts.some((fact) => /\bconcise\b/i.test(fact))) {
2538
+ contract.push('Answer contract: typed tone fact.');
2539
+ contract.push(' Mention the user preference for concise coaching cues using the word "concise".');
2540
+ }
2541
+
2257
2542
  if (route === 'progress_review') {
2258
2543
  const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
2259
2544
  const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
@@ -2263,6 +2548,7 @@ function appendAskAnswerContract(lines, {
2263
2548
  const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
2264
2549
  const strengthSessionCount = recentSessions?.rows?.length;
2265
2550
  const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
2551
+ const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
2266
2552
  const recentRecordCount = records?.facts?.recentRecordCount;
2267
2553
  const readinessFacts = readiness?.facts ?? {};
2268
2554
  const readinessPhrases = [
@@ -2276,10 +2562,14 @@ function appendAskAnswerContract(lines, {
2276
2562
  if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
2277
2563
  contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
2278
2564
  }
2279
- if (Number.isFinite(Number(currentSessions)) && Number.isFinite(Number(previousSessions))) {
2565
+ if (
2566
+ Number.isFinite(Number(currentSessions))
2567
+ && Number.isFinite(Number(previousSessions))
2568
+ && Number(currentSessions) === Number(previousSessions)
2569
+ ) {
2280
2570
  contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
2281
2571
  }
2282
- if (Number.isFinite(Number(volumeDeltaPct))) {
2572
+ if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
2283
2573
  const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
2284
2574
  contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
2285
2575
  }
@@ -2295,6 +2585,14 @@ function appendAskAnswerContract(lines, {
2295
2585
  contract.push(' End with this goal-clarifying question when no clear goal decides the tradeoff: "What are we measuring this against - size, strength, or staying lean?"');
2296
2586
  }
2297
2587
 
2588
+ if (route === 'program_history') {
2589
+ contract.push('Answer contract: program change history.');
2590
+ contract.push(' Answer only from the durable program change history evidence.');
2591
+ contract.push(' If the user asks to undo, revert, restore, or change it back, say automatic restore is not available yet in this slice.');
2592
+ contract.push(' Identify the likely target change by date, summary, kind, and id when the evidence supports it.');
2593
+ contract.push(' Do not claim the program was changed, restored, reverted, or updated.');
2594
+ }
2595
+
2298
2596
  if (route === 'recent_session' && sessionObservationComparisons.length > 0) {
2299
2597
  contract.push('Answer contract: current session plus durable observations.');
2300
2598
  contract.push(' Say what improved in the current session first.');
@@ -2306,7 +2604,7 @@ function appendAskAnswerContract(lines, {
2306
2604
  contract.push('Answer contract: verify the alleged drop-off against logged sets.');
2307
2605
  contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
2308
2606
  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.');
2607
+ 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
2608
  contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
2311
2609
  contract.push(' Do not mention record estimates unless the user asked for them.');
2312
2610
  }
@@ -2388,6 +2686,44 @@ function compactExerciseRows(items, key = 'exercise') {
2388
2686
  .map((name) => compactEvidenceRow(name, 'relevant lift history available'));
2389
2687
  }
2390
2688
 
2689
+ function muscleVolumeTrendEvidenceRows(evidence) {
2690
+ const rows = Array.isArray(evidence?.muscles)
2691
+ ? evidence.muscles
2692
+ : (evidence?.muscle ? [{ muscle: evidence.muscle }] : []);
2693
+ const isPartial = evidence?.currentWeek?.isPartial === true || evidence?.currentWeekIsPartial === true;
2694
+ return rows
2695
+ .slice(0, 3)
2696
+ .map((row) => {
2697
+ const muscle = String(row?.muscle ?? '').trim();
2698
+ if (!muscle) return null;
2699
+ return compactEvidenceRow(muscle, muscleVolumeTrendEvidenceValue(row, { isPartial }));
2700
+ })
2701
+ .filter(Boolean);
2702
+ }
2703
+
2704
+ function muscleVolumeTrendEvidenceValue(row, { isPartial = false } = {}) {
2705
+ const pieces = [];
2706
+ const share = wholePercent(row?.latestSharePct);
2707
+ if (share) pieces.push(`${share} of ${isPartial ? 'this week-to-date volume' : "this week's volume"}`);
2708
+ const delta = directionalPercent(row?.deltaVsPriorAvgPct);
2709
+ if (delta) pieces.push(delta);
2710
+ return pieces.length > 0 ? pieces.join(' · ') : 'muscle breakdown available';
2711
+ }
2712
+
2713
+ function wholePercent(value) {
2714
+ const number = Number(value);
2715
+ if (!Number.isFinite(number)) return null;
2716
+ return `${Math.round(number)}%`;
2717
+ }
2718
+
2719
+ function directionalPercent(value) {
2720
+ const number = Number(value);
2721
+ if (!Number.isFinite(number)) return null;
2722
+ const rounded = Math.round(number);
2723
+ if (rounded === 0) return 'flat versus recent weeks';
2724
+ return `${rounded > 0 ? 'up' : 'down'} ${Math.abs(rounded)}%`;
2725
+ }
2726
+
2391
2727
  function humanObservationEvidenceRows(observation) {
2392
2728
  const evidence = observation?.evidence;
2393
2729
  if (!evidence || typeof evidence !== 'object') return [];
@@ -2399,6 +2735,8 @@ function humanObservationEvidenceRows(observation) {
2399
2735
  rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
2400
2736
  }
2401
2737
  rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
2738
+ } else if (kind === 'muscle_volume_trend') {
2739
+ rows.push(...muscleVolumeTrendEvidenceRows(evidence));
2402
2740
  } else if (kind === 'training_balance_skew') {
2403
2741
  rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
2404
2742
  rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
@@ -2935,6 +3273,14 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2935
3273
  built = buildExerciseProgressSummaryAskContext(contextSnapshot, namedExerciseItems, { exclude, since, today });
2936
3274
  } else if (route === 'program_progress') {
2937
3275
  built = buildProgramProgressAskContext(contextSnapshot, { exclude, since, today });
3276
+ } else if (route === 'program_history') {
3277
+ built = buildProgramHistoryAskContext(contextSnapshot, { exclude, today });
3278
+ } else if (route === 'program_schedule_action') {
3279
+ built = buildProgramScheduleActionAskContext(contextSnapshot, question, {
3280
+ exclude,
3281
+ today,
3282
+ scheduleContext: evidencePlan.intent?.deloadScheduleContext ?? null
3283
+ });
2938
3284
  } else if (route === 'training_profile') {
2939
3285
  built = buildTrainingProfileAskContext(contextSnapshot, { exclude, since, today });
2940
3286
  } else if (route === 'records') {
@@ -2971,7 +3317,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2971
3317
  exclude,
2972
3318
  today,
2973
3319
  namedExercises: namedExerciseItems,
2974
- existingSections: built.sections
3320
+ existingSections: built.sections,
3321
+ omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
2975
3322
  })
2976
3323
  : { sections: [], tools: [], provenance: [] };
2977
3324
  built = {
@@ -3020,6 +3367,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3020
3367
  namedExerciseLabels,
3021
3368
  builtTools: tools,
3022
3369
  sessionObservationComparisons,
3370
+ includedFacts,
3023
3371
  question
3024
3372
  });
3025
3373
  const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
@@ -3037,7 +3385,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3037
3385
  ...(includedFacts.length > 0 ? ['coach_facts'] : []),
3038
3386
  ...(includedCoachObservationIds.length > 0 ? ['coach_observations'] : []),
3039
3387
  ...(sessionObservationComparisons.length > 0 ? ['session_observation_comparisons'] : [])
3040
- ]
3388
+ ],
3389
+ programScheduleActionStartDate: built.programScheduleActionStartDate
3041
3390
  };
3042
3391
  const finalizedEvidencePlan = finalizeEvidencePlan(evidencePlan, tools);
3043
3392
  const toolMetadata = askToolMetadata(tools, provenance, { requiredTools: finalizedEvidencePlan.requiredTools });
@@ -3077,6 +3426,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3077
3426
  includedCoachObservationIds,
3078
3427
  coachObservationIds: includedCoachObservationIds,
3079
3428
  currentSessionIds,
3429
+ ...(built.programScheduleActionStartDate ? { programScheduleActionStartDate: built.programScheduleActionStartDate } : {}),
3080
3430
  sessionObservationComparisons,
3081
3431
  evidencePlan: finalizedEvidencePlan,
3082
3432
  contextBundle: contextBundleForMetadata(contextBundle),