incremnt 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,11 +59,8 @@ incremnt login --session-file ~/Downloads/session.json
59
59
  | `programs current` | Active program state |
60
60
  | `programs list` | All programs |
61
61
  | `programs show --id <id>` | Full program detail |
62
- | `cycles list [--program-id <id>]` | Completed cycle summaries |
63
- | `cycles show --id <id>` | Details for a completed cycle summary |
64
62
  | `exercises history --name <name>` | Set-by-set history for an exercise |
65
63
  | `records` | Personal records (best e1RM per exercise) |
66
- | `goals list` / `goals show --id <id>` | Strength plan goals |
67
64
  | `health summary` / `health ai` | Health metrics and AI summary |
68
65
  | `training load` | ATL/CTL/TSB and workload context |
69
66
  | `ask history` / `ask show --id <id>` | Coach conversation history |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/auth.js CHANGED
@@ -275,7 +275,11 @@ async function fetchRemoteContract(baseUrl, token) {
275
275
 
276
276
  const payload = await response.json();
277
277
  if (payload.contractVersion !== contractVersion) {
278
- const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got v${payload.contractVersion}.`);
278
+ const cliBehind = typeof payload.contractVersion === 'number' && payload.contractVersion > contractVersion;
279
+ const hint = cliBehind
280
+ ? ' Your CLI is out of date — run `npm install -g incremnt@latest` to upgrade.'
281
+ : ' The sync service is older than this CLI — wait for the service to redeploy, or use an older CLI version.';
282
+ const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got v${payload.contractVersion}.${hint}`);
279
283
  error.code = 'REMOTE_CONTRACT_MISMATCH';
280
284
  throw error;
281
285
  }
package/src/contract.js CHANGED
@@ -87,13 +87,15 @@ export const commandSchema = [
87
87
  {
88
88
  command: 'goals list',
89
89
  id: 'goals-list',
90
- description: 'List strength plans and lift goals',
90
+ description: 'Legacy read-only strength plan and lift goal data',
91
+ hidden: true,
91
92
  options: []
92
93
  },
93
94
  {
94
95
  command: 'goals show',
95
96
  id: 'goals-show',
96
- description: 'Show strength plan goal details',
97
+ description: 'Legacy read-only strength plan goal details',
98
+ hidden: true,
97
99
  usage: 'goals show --id <plan-id>',
98
100
  options: [
99
101
  { name: 'id', type: 'string', required: true, description: 'Plan ID' }
@@ -102,7 +104,8 @@ export const commandSchema = [
102
104
  {
103
105
  command: 'cycles list',
104
106
  id: 'cycle-summary-list',
105
- description: 'List cycle summaries',
107
+ description: 'Legacy read-only cycle summaries',
108
+ hidden: true,
106
109
  options: [
107
110
  { name: 'program-id', type: 'string', required: false, description: 'Filter by program ID' }
108
111
  ]
@@ -110,7 +113,8 @@ export const commandSchema = [
110
113
  {
111
114
  command: 'cycles show',
112
115
  id: 'cycle-summary-show',
113
- description: 'Show cycle summary details',
116
+ description: 'Legacy read-only cycle summary details',
117
+ hidden: true,
114
118
  usage: 'cycles show --id <summary-id>',
115
119
  options: [
116
120
  { name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
package/src/format.js CHANGED
@@ -755,7 +755,7 @@ export function formatHelp(opts = {}) {
755
755
  ` incremnt <command> [options]`,
756
756
  '',
757
757
  header('COMMANDS'),
758
- ...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
758
+ ...commandSchema.filter((c) => !c.hidden).map((c) => cmd(c.usage ?? c.command, c.description)),
759
759
  '',
760
760
  header('WRITE COMMANDS'),
761
761
  ...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
package/src/lib.js CHANGED
@@ -73,7 +73,15 @@ export async function runCli(argv, stdout, stderr) {
73
73
  })[command] ?? command;
74
74
 
75
75
  const sessionState = await readSessionState();
76
- const isAuthenticated = Boolean(sessionState?.session && !isSessionExpired(sessionState.session));
76
+ const hasTransport = Boolean(
77
+ sessionState?.session?.transport?.baseUrl
78
+ || sessionState?.session?.transport?.fixturePath
79
+ );
80
+ const isAuthenticated = Boolean(
81
+ sessionState?.session
82
+ && !isSessionExpired(sessionState.session)
83
+ && hasTransport
84
+ );
77
85
 
78
86
  if (!command || options.help) {
79
87
  await printLogo(stdout);
package/src/openrouter.js CHANGED
@@ -70,8 +70,7 @@ function exerciseNamesFromContext(source) {
70
70
  source.exercises?.map((exercise) => exercise.exerciseName ?? exercise.name),
71
71
  source.sessions?.flatMap((session) => session.exercises?.map((exercise) => exercise.exerciseName ?? exercise.name) ?? []),
72
72
  source.prsThisWeek?.map((pr) => pr.exerciseName),
73
- source.stalledExercises?.map((exercise) => exercise.exerciseName),
74
- source.goalProgress?.map((goal) => goal.exerciseName)
73
+ source.stalledExercises?.map((exercise) => exercise.exerciseName)
75
74
  ]);
76
75
  }
77
76
 
@@ -108,8 +107,7 @@ function includedSectionsForSurface(surface, source) {
108
107
  return [
109
108
  'week',
110
109
  hasItems(source.prsThisWeek) ? 'prs' : null,
111
- hasItems(source.stalledExercises) ? 'stalled_exercises' : null,
112
- hasItems(source.goalProgress) ? 'goal_progress' : null
110
+ hasItems(source.stalledExercises) ? 'stalled_exercises' : null
113
111
  ].filter(Boolean);
114
112
  default:
115
113
  return [];
@@ -713,12 +711,6 @@ export function formatCycleContext(ctx) {
713
711
  `Program: ${ctx.programName}, Week ${ctx.cycleNumber}${intentLabel}, ${ctx.totalSessions} session(s).`
714
712
  ];
715
713
 
716
- const phaseLines = formatProgramPhaseContext(ctx.programPhase);
717
- if (phaseLines.length > 0) {
718
- lines.push('');
719
- lines.push(...phaseLines);
720
- }
721
-
722
714
  if (ctx.prioritySignals?.length > 0) {
723
715
  lines.push('');
724
716
  lines.push('Priority signals (ranked):');
@@ -798,14 +790,6 @@ export function formatCycleContext(ctx) {
798
790
  }
799
791
  }
800
792
 
801
- if (ctx.goalProgress) {
802
- lines.push('');
803
- lines.push('Goal progress:');
804
- for (const g of ctx.goalProgress) {
805
- lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
806
- }
807
- }
808
-
809
793
  if (ctx.previousCycles?.length > 0) {
810
794
  lines.push('');
811
795
  lines.push('Previous cycles:');
@@ -963,10 +947,6 @@ export function formatWorkoutContext(ctx) {
963
947
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
964
948
  lines.push(`Completed: ${dayNames[d.getUTCDay()]}, ${timeOfDay}.`);
965
949
  }
966
- if (ctx.programWeekNumber) {
967
- const phase = ctx.programProgressionType ? ` (${ctx.programProgressionType})` : '';
968
- lines.push(`Program week: ${ctx.programWeekNumber}${phase}.`);
969
- }
970
950
  if (ctx.sessionsThisWeek) {
971
951
  lines.push(`Sessions this week: ${ctx.sessionsThisWeek}.`);
972
952
  }
@@ -1201,12 +1181,6 @@ export function formatCheckpointContext(ctx) {
1201
1181
  `Program: ${ctx.programName}, Checkpoint at week ${ctx.checkpointWeek} of ${ctx.totalWeeks}.`
1202
1182
  ];
1203
1183
 
1204
- const phaseLines = formatProgramPhaseContext(ctx.programPhase);
1205
- if (phaseLines.length > 0) {
1206
- lines.push('');
1207
- lines.push(...phaseLines);
1208
- }
1209
-
1210
1184
  lines.push('');
1211
1185
  lines.push('Exercise targets:');
1212
1186
  for (const ex of ctx.exercises) {
@@ -1419,10 +1393,6 @@ function formatWeeklyCheckinContext(context) {
1419
1393
  const lines = [];
1420
1394
  lines.push(`Today: ${context.todayIso}`);
1421
1395
  lines.push(`Week range: ${context.weekRangeIso?.start} to ${context.weekRangeIso?.end}`);
1422
- const phaseLines = formatProgramPhaseContext(context.programPhase);
1423
- if (phaseLines.length > 0) {
1424
- lines.push(...phaseLines);
1425
- }
1426
1396
  lines.push(`Sessions this week: ${context.sessionCount}`);
1427
1397
  if (context.adherencePct != null) {
1428
1398
  lines.push(`Adherence: ${context.completedSets}/${context.plannedSets} sets (${context.adherencePct}%)`);
@@ -1446,47 +1416,9 @@ function formatWeeklyCheckinContext(context) {
1446
1416
  const sign = context.bodyweightDeltaKg >= 0 ? '+' : '';
1447
1417
  lines.push(`Bodyweight 7d delta: ${sign}${context.bodyweightDeltaKg}kg`);
1448
1418
  }
1449
- if (Array.isArray(context.goalProgress) && context.goalProgress.length > 0) {
1450
- lines.push('Goal progress:');
1451
- for (const g of context.goalProgress) {
1452
- const deadline = g.finishDate ? ` (finish ${g.finishDate})` : '';
1453
- lines.push(` - ${g.exerciseName}: ${g.progressPercent}% toward ${g.targetE1RM}kg${deadline}`);
1454
- }
1455
- }
1456
1419
  return lines.join('\n');
1457
1420
  }
1458
1421
 
1459
- export function formatProgramPhaseContext(programPhase) {
1460
- if (!programPhase || typeof programPhase !== 'object') return [];
1461
- const current = programPhase.current;
1462
- if (!current?.phase || typeof current.displayWeek !== 'number') return [];
1463
-
1464
- const describe = (phase) => {
1465
- if (!phase?.phase) return null;
1466
- const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
1467
- return `${week} ${phase.phase}${phase.isDeload ? ' (deload)' : ''}`;
1468
- };
1469
- const describeList = (phases) => {
1470
- if (!Array.isArray(phases) || phases.length === 0) return null;
1471
- return phases.map(describe).filter(Boolean).join(', ');
1472
- };
1473
-
1474
- const lines = ['Program phase:'];
1475
- lines.push(` Current: ${describe(current)}`);
1476
- const previous = describe(programPhase.previousWeek);
1477
- if (previous) lines.push(` Previous: ${previous}`);
1478
- const next = describe(programPhase.nextWeek);
1479
- if (next) lines.push(` Next: ${next}`);
1480
- if (programPhase.isPostDeloadReturn === true) {
1481
- lines.push(' Post-deload return: yes');
1482
- }
1483
- const range = describeList(programPhase.phasesInRange);
1484
- if (range) lines.push(` Range phases: ${range}`);
1485
- const previousRange = describeList(programPhase.previousRangePhases);
1486
- if (previousRange) lines.push(` Previous range phases: ${previousRange}`);
1487
- return lines;
1488
- }
1489
-
1490
1422
  export async function generateWeeklyCheckinRecap(context, { apiKey, model, timeoutMs, priorCommitment, user, sessionId, contextMetadata } = {}) {
1491
1423
  const contextText = formatWeeklyCheckinContext(context);
1492
1424
  const userLines = [fenceContent('training_data', contextText)];
package/src/queries.js CHANGED
@@ -1,12 +1,148 @@
1
1
  import { coachFactPolicyViolation } from './coach-facts.js';
2
2
  import { exerciseAliasMapping } from './exercise-aliases.js';
3
- import { programPhaseWindowContext, resolveProgramPhase } from './program-phase-resolver.js';
3
+ import { resolveProgramPhase } from './program-phase-resolver.js';
4
4
  import { enrichScoreSnapshots } from './score-context.js';
5
5
 
6
6
  function completionDateForSession(session) {
7
7
  return session.completedAt ?? session.summary?.date ?? session.date;
8
8
  }
9
9
 
10
+ const WEEKDAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
11
+
12
+ function isoWeekdayOf(date) {
13
+ const jsDay = date.getDay();
14
+ return jsDay === 0 ? 7 : jsDay;
15
+ }
16
+
17
+ function dateOnly(date) {
18
+ return localDateString(date);
19
+ }
20
+
21
+ function shiftDate(date, days) {
22
+ const d = new Date(date);
23
+ d.setDate(d.getDate() + days);
24
+ return d;
25
+ }
26
+
27
+ /**
28
+ * Mirror of iOS DashboardSchedulingLogic for AI prompt builders.
29
+ * Resolves the program's schedule position into one of:
30
+ * - 'catchUp' — currentDayIndex's scheduled weekday already passed this week
31
+ * and that index isn't in completedDayIndices.
32
+ * - 'today' — currentDayIndex's scheduled weekday is today.
33
+ * - 'upcoming' — scheduled later this week or next.
34
+ *
35
+ * Without this, the naive `(trainingWeekdays[currentDayIndex] - todayIso + 7) % 7`
36
+ * surfaces a missed Friday session as "Friday (in 6 days)" the morning after.
37
+ */
38
+ export function resolveProgramSchedule(program, now = new Date()) {
39
+ if (!program) return null;
40
+ const trainingWeekdays = program.trainingWeekdays ?? [];
41
+ const days = program.days ?? [];
42
+ const dayCount = days.length;
43
+ if (dayCount === 0) return null;
44
+
45
+ const currentDayIndex = program.currentDayIndex ?? 0;
46
+ if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= dayCount) {
47
+ return null;
48
+ }
49
+ const scheduledWeekday = trainingWeekdays[currentDayIndex];
50
+ if (scheduledWeekday == null) return null;
51
+
52
+ const todayIso = isoWeekdayOf(now);
53
+ const completed = new Set(program.completedDayIndices ?? []);
54
+ const dayTitle = days[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
55
+
56
+ // Mirror DashboardSchedulingLogic.shouldShowCatchUpWorkout: cycle has started
57
+ // (some completions exist) and currentDayIndex itself isn't completed.
58
+ const cycleStarted = completed.size > 0;
59
+ const currentIncomplete = !completed.has(currentDayIndex);
60
+ const nextScheduledSession = trainingWeekdays
61
+ .map((weekday, dayIndex) => ({ weekday, dayIndex }))
62
+ .filter(({ weekday, dayIndex }) => (
63
+ dayIndex < dayCount
64
+ && weekday > todayIso
65
+ && !completed.has(dayIndex)
66
+ ))
67
+ .sort((a, b) => a.weekday - b.weekday)[0] ?? null;
68
+
69
+ if (
70
+ scheduledWeekday < todayIso
71
+ && cycleStarted
72
+ && currentIncomplete
73
+ ) {
74
+ const daysAgo = todayIso - scheduledWeekday;
75
+ const resolved = {
76
+ state: 'catchUp',
77
+ dayIndex: currentDayIndex,
78
+ dayTitle,
79
+ scheduledWeekday,
80
+ scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
81
+ scheduledOn: dateOnly(shiftDate(now, -daysAgo)),
82
+ daysAgo
83
+ };
84
+ if (nextScheduledSession) {
85
+ const daysUntil = nextScheduledSession.weekday - todayIso;
86
+ resolved.nextScheduledSession = {
87
+ state: 'upcoming',
88
+ dayIndex: nextScheduledSession.dayIndex,
89
+ dayTitle: days[nextScheduledSession.dayIndex]?.title ?? `Day ${nextScheduledSession.dayIndex + 1}`,
90
+ scheduledWeekday: nextScheduledSession.weekday,
91
+ scheduledWeekdayName: WEEKDAY_NAMES[nextScheduledSession.weekday],
92
+ scheduledOn: dateOnly(shiftDate(now, daysUntil)),
93
+ daysUntil
94
+ };
95
+ }
96
+ return resolved;
97
+ }
98
+
99
+ let daysUntil = scheduledWeekday - todayIso;
100
+ if (daysUntil < 0) daysUntil += 7;
101
+ const state = daysUntil === 0 ? 'today' : 'upcoming';
102
+ return {
103
+ state,
104
+ dayIndex: currentDayIndex,
105
+ dayTitle,
106
+ scheduledWeekday,
107
+ scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
108
+ scheduledOn: dateOnly(shiftDate(now, daysUntil)),
109
+ daysUntil
110
+ };
111
+ }
112
+
113
+ function formatScheduleLine(resolved) {
114
+ if (!resolved) return null;
115
+ if (resolved.state === 'catchUp') {
116
+ const next = resolved.nextScheduledSession;
117
+ const nextLine = next
118
+ ? ` Next scheduled session: ${next.dayTitle} on ${next.scheduledWeekdayName} (${next.daysUntil === 1 ? 'tomorrow' : `in ${next.daysUntil} days`}).`
119
+ : '';
120
+ return `Missed scheduled session: ${resolved.dayTitle} (was ${resolved.scheduledWeekdayName} ${resolved.scheduledOn}, not completed). Available to catch up.${nextLine}`;
121
+ }
122
+ if (resolved.state === 'today') {
123
+ return `Today's scheduled session: ${resolved.dayTitle} (${resolved.scheduledWeekdayName}).`;
124
+ }
125
+ const whenLabel = resolved.daysUntil === 1 ? 'tomorrow' : `in ${resolved.daysUntil} days`;
126
+ return `Next scheduled session: ${resolved.dayTitle} on ${resolved.scheduledWeekdayName} (${whenLabel}).`;
127
+ }
128
+
129
+ function completedThisCycleLines(program) {
130
+ if (!program) return [];
131
+ const completed = Array.from(new Set(program.completedDayIndices ?? []))
132
+ .filter((idx) => Number.isInteger(idx))
133
+ .sort((a, b) => a - b);
134
+ if (completed.length === 0) return [];
135
+ const days = program.days ?? [];
136
+ const trainingWeekdays = program.trainingWeekdays ?? [];
137
+ const parts = completed.map((idx) => {
138
+ const title = days[idx]?.title ?? `Day ${idx + 1}`;
139
+ const wd = trainingWeekdays[idx];
140
+ const wdName = wd != null ? WEEKDAY_NAMES[wd]?.slice(0, 3) : null;
141
+ return wdName ? `${title} (${wdName})` : title;
142
+ });
143
+ return [`Completed this cycle: ${parts.join(', ')}.`];
144
+ }
145
+
10
146
  function normalizedNote(note) {
11
147
  if (typeof note !== 'string') return null;
12
148
  const trimmed = note.trim();
@@ -1101,38 +1237,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1101
1237
  return { original, replacement, count };
1102
1238
  });
1103
1239
 
1104
- let goalProgress = null;
1105
- const plans = snapshot.strengthPlans ?? [];
1106
- const activePlan = plans.find(
1107
- (p) => p.status === 'active' && p.programId === program.id
1108
- );
1109
- if (activePlan) {
1110
- const programExerciseNames = new Set(
1111
- (program.days ?? [])
1112
- .flatMap((day) => day.exercises ?? [])
1113
- .map((exercise) => canonicalExerciseName(exercise.name ?? exercise.exerciseName))
1114
- );
1115
- goalProgress = (activePlan.liftGoals ?? [])
1116
- .filter((g) => {
1117
- if (programExerciseNames.size === 0) return true;
1118
- return programExerciseNames.has(canonicalExerciseName(g.exerciseDisplayName));
1119
- })
1120
- .map((g) => {
1121
- const range = g.targetE1RM - g.startingE1RM;
1122
- const gained = g.currentBestE1RM - g.startingE1RM;
1123
- const progressPct =
1124
- range > 0 ? Math.max(0, Math.round((gained / range) * 100)) : null;
1125
- return {
1126
- exerciseName: g.exerciseDisplayName,
1127
- progressPercent: progressPct,
1128
- currentBestE1RM: g.currentBestE1RM,
1129
- targetE1RM: g.targetE1RM,
1130
- goalAdjustmentAction: g.goalAdjustmentAction ?? null,
1131
- goalAdjustedAt: g.goalAdjustedAt ?? null
1132
- };
1133
- });
1134
- }
1135
-
1136
1240
  const matchingSummary = programCycleSummaries[0] ?? null;
1137
1241
 
1138
1242
  const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
@@ -1236,39 +1340,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1236
1340
  actionability: 9
1237
1341
  });
1238
1342
  }
1239
- if (goalProgress?.length > 0) {
1240
- const lagging = goalProgress.filter((g) => g.progressPercent != null && g.progressPercent < 40);
1241
- if (lagging.length > 0) {
1242
- cycleSignals.push({
1243
- id: 'goal-lagging',
1244
- category: 'goals',
1245
- summary: `${lagging.length} goal${lagging.length === 1 ? '' : 's'} under 40% progress`,
1246
- detail: lagging.map((g) => `${g.exerciseName} ${g.progressPercent}%`).join(', '),
1247
- impact: 9,
1248
- confidence: 8,
1249
- novelty: 6,
1250
- actionability: 9
1251
- });
1252
- }
1253
- }
1254
-
1255
- // Phase-window context (Step 9b of the deload-week unification plan): explicit
1256
- // structured phase facts so prompt builders / models never have to infer
1257
- // "is this a deload week?" from session prose.
1258
- const phaseRangeStart = cycleSessions[0]?.completedAt ?? cycleSessions[0]?.date ?? null;
1259
- const phaseRangeEnd = cycleSessions[cycleSessions.length - 1]?.completedAt
1260
- ?? cycleSessions[cycleSessions.length - 1]?.date
1261
- ?? null;
1262
- const summaryRange = phaseRangeStart && phaseRangeEnd
1263
- ? { start: phaseRangeStart, end: phaseRangeEnd }
1264
- : null;
1265
- const programPhase = programPhaseWindowContext(
1266
- program,
1267
- activeStrengthPlanForProgram(snapshot, program.id),
1268
- summaryRange,
1269
- new Date()
1270
- );
1271
-
1272
1343
  return {
1273
1344
  programName: program.name,
1274
1345
  cycleNumber: cycleWeekNumber,
@@ -1276,7 +1347,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1276
1347
  sessions,
1277
1348
  prsThisCycle,
1278
1349
  bwPrsThisCycle,
1279
- goalProgress,
1280
1350
  progressionDecisions,
1281
1351
  setCompletionRate,
1282
1352
  swapPatterns,
@@ -1291,7 +1361,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1291
1361
  avgSleepMins,
1292
1362
  latestBodyWeightKg,
1293
1363
  prioritySignals: rankPrioritySignals(cycleSignals),
1294
- programPhase,
1295
1364
  excludeNote: buildExcludeNote(exclude)
1296
1365
  };
1297
1366
  }
@@ -1373,26 +1442,12 @@ export function checkpointContext(snapshot, programId, checkpointWeek, { exclude
1373
1442
  .slice(0, 3);
1374
1443
  const previousCycleNotes = programCycleSummaries.map((cs) => cs.aiSummary);
1375
1444
 
1376
- // Phase-window context (Step 9b). Scoped to a 14-day window ending today
1377
- // so phasesInRange covers "current week" + "previous week" — enough for
1378
- // the model to spot post-deload-return / pre-deload patterns without
1379
- // bloating the prompt with the entire plan timeline.
1380
- const checkpointToday = new Date();
1381
- const checkpointStart = new Date(checkpointToday.getTime() - 14 * 24 * 60 * 60 * 1000);
1382
- const programPhase = programPhaseWindowContext(
1383
- program,
1384
- activeStrengthPlanForProgram(snapshot, program.id),
1385
- { start: checkpointStart, end: checkpointToday },
1386
- checkpointToday
1387
- );
1388
-
1389
1445
  return {
1390
1446
  programName: program.name,
1391
1447
  checkpointWeek,
1392
1448
  totalWeeks,
1393
1449
  exercises,
1394
1450
  previousCycleNotes,
1395
- programPhase,
1396
1451
  excludeNote: buildExcludeNote(exclude)
1397
1452
  };
1398
1453
  }
@@ -1402,6 +1457,7 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1402
1457
  const session = sessions.find((s) => s.id === sessionId);
1403
1458
  if (!session) return null;
1404
1459
 
1460
+ const today = new Date();
1405
1461
  const sessionDate = completionDateForSession(session);
1406
1462
  const dayName = session.dayName ?? 'Session';
1407
1463
  const earlierSessions = sessions
@@ -1715,14 +1771,16 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1715
1771
  const nextExercises = ((program.days ?? [])[currentDayIndex]?.exercises ?? [])
1716
1772
  .map(ex => ex.exerciseName ?? ex.name)
1717
1773
  .filter(Boolean);
1718
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
1719
- let weekdayName = null;
1720
- if (nextSessionWeekday != null) {
1721
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
1722
- weekdayName = dayNames[nextSessionWeekday] ?? null;
1723
- }
1774
+ const schedule = resolveProgramSchedule(program, today);
1724
1775
  if (nextDayTitle) {
1725
- nextSession = { dayTitle: nextDayTitle, weekday: weekdayName, exerciseNames: nextExercises };
1776
+ nextSession = {
1777
+ dayTitle: nextDayTitle,
1778
+ weekday: schedule?.scheduledWeekdayName ?? null,
1779
+ exerciseNames: nextExercises,
1780
+ state: schedule?.state ?? null,
1781
+ scheduledOn: schedule?.scheduledOn ?? null,
1782
+ nextScheduledSession: schedule?.nextScheduledSession ?? null
1783
+ };
1726
1784
  }
1727
1785
  }
1728
1786
  }
@@ -1997,30 +2055,13 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
1997
2055
  lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
1998
2056
  }
1999
2057
 
2000
- // Current program + week phase. Guided programs use ProgramPhaseResolver so
2001
- // coach text cannot contradict the structured programPhase prelude.
2058
+ // Current program context without plan-week or phase framing. Score is the
2059
+ // release progress model; program context is only for what the user trains.
2002
2060
  const program = activeProgram(snapshot);
2003
2061
  if (program) {
2004
2062
  const recoveryOutcome = exclude.has('recovery') ? null : deriveRecoveryOutcome(snapshot, program);
2005
- const programSessions = sessions
2006
- .filter((s) => s.programId === program.id && s.historicalContext?.programWeekNumber)
2007
- .sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
2008
- const latestSession = programSessions[0];
2009
- const phase = resolveCurrentProgramPhase(snapshot, program, today);
2010
- const currentWeek = phase?.displayWeek
2011
- ?? Math.max(
2012
- Number(program.completedCyclesCount ?? 0) + 1,
2013
- Number(latestSession?.historicalContext?.programWeekNumber ?? 0),
2014
- 1
2015
- );
2016
- const weekPhase = phase?.phase
2017
- ?? latestSession?.historicalContext?.programProgressionType
2018
- ?? null;
2019
- const phaseLabel = weekPhase ? ` (${weekPhase} week)` : '';
2020
- lines.push(`Current program: ${program.name}, week ${currentWeek}${phaseLabel}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2021
- if (weekPhase === 'deload') {
2022
- lines.push('Note: This is a planned deload week — reduced volume and intensity are intentional, not a regression.');
2023
- }
2063
+ const daysPerWeek = program.daysPerWeek ?? program.days?.length ?? 'unknown';
2064
+ lines.push(`Current program: ${program.name}, ${daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2024
2065
 
2025
2066
  const weekStart = startOfCurrentIsoWeek(today);
2026
2067
  const strengthSessionsThisWeek = sessions.filter((session) => {
@@ -2038,30 +2079,21 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
2038
2079
  lines.push(`Last strength session: ${lastStrengthSessionDate}.`);
2039
2080
  }
2040
2081
 
2041
- const latestAdaptation = Array.isArray(program.adaptationEvents) && program.adaptationEvents.length > 0
2042
- ? program.adaptationEvents[0]
2043
- : null;
2044
- if (latestAdaptation?.actionRawValue === 'skipToNextWeek') {
2045
- const adaptedAt = String(latestAdaptation.occurredAt ?? '').slice(0, 10);
2046
- const suffix = adaptedAt ? ` on ${adaptedAt}` : '';
2047
- const adaptationDetails = normalizedNote(latestAdaptation.details);
2048
- const detailsSuffix = adaptationDetails ? ` ${adaptationDetails}` : '';
2049
- lines.push(`Latest program adaptation: Week skipped${suffix}.${detailsSuffix}`);
2082
+ // Days until next session respects iOS catch-up semantics.
2083
+ const schedule = resolveProgramSchedule(program, today);
2084
+ const scheduleLine = formatScheduleLine(schedule);
2085
+ if (scheduleLine) {
2086
+ // workout-summary historically uses "Next session:" lead — preserve when not catching up.
2087
+ if (schedule.state === 'catchUp') {
2088
+ lines.push(scheduleLine);
2089
+ } else if (schedule.state === 'today') {
2090
+ lines.push(`Next session: ${schedule.dayTitle} (today).`);
2091
+ } else {
2092
+ const whenLabel = schedule.daysUntil === 1 ? 'tomorrow' : `in ${schedule.daysUntil} days`;
2093
+ lines.push(`Next session: ${schedule.dayTitle} on ${schedule.scheduledWeekdayName} (${whenLabel}).`);
2094
+ }
2050
2095
  }
2051
-
2052
- // Days until next session
2053
2096
  const currentDayIndex = program.currentDayIndex ?? 0;
2054
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
2055
- if (nextSessionWeekday != null) {
2056
- const jsDay = new Date().getDay(); // 0=Sun
2057
- const todayWeekday = jsDay === 0 ? 7 : jsDay; // 1=Mon … 7=Sun
2058
- let daysUntil = nextSessionWeekday - todayWeekday;
2059
- if (daysUntil < 0) daysUntil += 7;
2060
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
2061
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
2062
- const nextDayTitle = (program.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
2063
- lines.push(`Next session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
2064
- }
2065
2097
 
2066
2098
  if (recoveryOutcome) {
2067
2099
  lines.push(`Recovery update: ${recoveryOutcome.scheduleLine} ${recoveryOutcome.targetLine} ${recoveryOutcome.nextStepLine}`);
@@ -4102,7 +4134,8 @@ function weeklyActivitySummary(metrics) {
4102
4134
 
4103
4135
  export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4104
4136
  const lines = [];
4105
- lines.push(`Date: ${new Date().toISOString().slice(0, 10)}`);
4137
+ const today = new Date();
4138
+ lines.push(`Date: ${dateOnly(today)} (${WEEKDAY_NAMES[isoWeekdayOf(today)]})`);
4106
4139
 
4107
4140
  // Training context
4108
4141
  const cutoff = new Date();
@@ -4144,18 +4177,10 @@ export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4144
4177
  const prog = snapshot.currentProgram ?? activeProgram(snapshot);
4145
4178
  if (prog) {
4146
4179
  lines.push(`Current program: ${prog.name}.`);
4147
- const currentDayIndex = prog.currentDayIndex ?? 0;
4148
- const nextSessionWeekday = (prog.trainingWeekdays ?? [])[currentDayIndex];
4149
- if (nextSessionWeekday != null) {
4150
- const jsDay = new Date().getDay();
4151
- const todayIso = jsDay === 0 ? 7 : jsDay;
4152
- let daysUntil = nextSessionWeekday - todayIso;
4153
- if (daysUntil < 0) daysUntil += 7;
4154
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
4155
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
4156
- const nextDayTitle = (prog.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
4157
- lines.push(`Next scheduled session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
4158
- }
4180
+ for (const line of completedThisCycleLines(prog)) lines.push(line);
4181
+ const schedule = resolveProgramSchedule(prog, today);
4182
+ const scheduleLine = formatScheduleLine(schedule);
4183
+ if (scheduleLine) lines.push(scheduleLine);
4159
4184
  }
4160
4185
 
4161
4186
  const vitals = healthSummary(snapshot, 14);
@@ -4453,19 +4478,25 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
4453
4478
  // ---------- Weekly Coach Check-in ----------
4454
4479
  // Builds a rolling 7-day context for the Sunday Coach Check-in.
4455
4480
  // See docs/plans/2026-04-23-001-feat-sunday-coach-checkin-plan-deepened.md.
4456
- export function weeklyCheckinContext(snapshot, accountId) {
4481
+ // `now` (optional, in options) anchors the 7-day window to a caller-provided
4482
+ // reference time instead of real time. The cron uses this to pin the window
4483
+ // to `row.week_start_date` so a late catch-up run still reports the canonical
4484
+ // Sun→Sun week rather than a Tue→Tue rolling slice. Defaults to new Date().
4485
+ export function weeklyCheckinContext(snapshot, accountId, { now: providedNow } = {}) {
4457
4486
  if (!snapshot) return null;
4458
4487
  const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
4459
- const now = new Date();
4488
+ const now = providedNow instanceof Date && !Number.isNaN(providedNow.getTime())
4489
+ ? providedNow
4490
+ : new Date();
4460
4491
  const todayIso = now.toISOString().slice(0, 10);
4461
- const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
4462
- const program = activeProgram(snapshot);
4463
-
4492
+ const cutoff = new Date(now);
4493
+ cutoff.setUTCHours(0, 0, 0, 0);
4494
+ cutoff.setUTCDate(cutoff.getUTCDate() - 7);
4464
4495
  const weekSessions = sessions.filter((s) => {
4465
4496
  const d = completionDateForSession(s);
4466
4497
  if (!d) return false;
4467
4498
  const dt = new Date(d);
4468
- return !Number.isNaN(dt.getTime()) && dt >= cutoff;
4499
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4469
4500
  });
4470
4501
 
4471
4502
  // Sessions prior to this week for stall/PR comparison.
@@ -4532,6 +4563,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4532
4563
  }
4533
4564
 
4534
4565
  // Stalled exercises: e1RM hasn't increased in 3+ consecutive weeks.
4566
+ // Bounded by `now` so anchored windows ignore sessions after the reference.
4535
4567
  const stalled = [];
4536
4568
  const byExercise = new Map();
4537
4569
  for (const s of sessions) {
@@ -4539,6 +4571,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4539
4571
  if (!d) continue;
4540
4572
  const dt = new Date(d);
4541
4573
  if (Number.isNaN(dt.getTime())) continue;
4574
+ if (dt >= now) continue;
4542
4575
  for (const ex of s.exercises ?? []) {
4543
4576
  if (!ex.name) continue;
4544
4577
  let top = 0;
@@ -4576,7 +4609,10 @@ export function weeklyCheckinContext(snapshot, accountId) {
4576
4609
  .slice()
4577
4610
  .filter((e) => e && e.date && Number.isFinite(Number(e.value ?? e.weight)))
4578
4611
  .sort((a, b) => String(a.date).localeCompare(String(b.date)));
4579
- const recent = sorted.filter((e) => new Date(e.date) >= cutoff);
4612
+ const recent = sorted.filter((e) => {
4613
+ const dt = new Date(e.date);
4614
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4615
+ });
4580
4616
  if (recent.length >= 2) {
4581
4617
  const first = Number(recent[0].value ?? recent[0].weight);
4582
4618
  const last = Number(recent[recent.length - 1].value ?? recent[recent.length - 1].weight);
@@ -4584,38 +4620,6 @@ export function weeklyCheckinContext(snapshot, accountId) {
4584
4620
  }
4585
4621
  }
4586
4622
 
4587
- // Goal trajectory: read from active StrengthPlan liftGoals.
4588
- let goalProgress = [];
4589
- const plans = Array.isArray(snapshot.strengthPlans) ? snapshot.strengthPlans : [];
4590
- const activePlan = program
4591
- ? activeStrengthPlanForProgram(snapshot, program.id)
4592
- : plans.find((p) => p?.status === 'active');
4593
- if (activePlan && Array.isArray(activePlan.liftGoals)) {
4594
- for (const goal of activePlan.liftGoals) {
4595
- const start = Number(goal.startingE1RM ?? 0);
4596
- const target = Number(goal.targetE1RM ?? 0);
4597
- const current = Number(goal.currentBestE1RM ?? 0);
4598
- if (target <= 0 || target === start) continue;
4599
- const pct = Math.max(0, Math.min(100, Math.round(((current - start) / (target - start)) * 100)));
4600
- goalProgress.push({
4601
- exerciseName: goal.exerciseDisplayName ?? goal.exerciseName ?? 'goal',
4602
- progressPercent: pct,
4603
- currentE1RM: Math.round(current * 10) / 10,
4604
- targetE1RM: target,
4605
- finishDate: activePlan.finishDate ?? null,
4606
- });
4607
- }
4608
- }
4609
-
4610
- const programPhase = program
4611
- ? programPhaseWindowContext(
4612
- program,
4613
- activeStrengthPlanForProgram(snapshot, program.id),
4614
- { start: cutoff, end: now },
4615
- now
4616
- )
4617
- : null;
4618
-
4619
4623
  // Prior commitment from typed coach_commitments storage (caller may inject).
4620
4624
  const context = {
4621
4625
  accountId,
@@ -4629,10 +4633,121 @@ export function weeklyCheckinContext(snapshot, accountId) {
4629
4633
  prsThisWeek: prs,
4630
4634
  stalledExercises: stalled.slice(0, 5),
4631
4635
  bodyweightDeltaKg: bodyweightDelta,
4632
- goalProgress,
4633
- programPhase,
4634
4636
  // Placeholder for injection by the handler; not a secret, just coherent.
4635
4637
  priorCommitment: null,
4636
4638
  };
4637
4639
  return context;
4638
4640
  }
4641
+
4642
+ // ---------- Weekly score digest (onemore-3s7j) ----------
4643
+ // Pure derivation: given the existing weekly check-in context (sessions,
4644
+ // volume, adherence, PRs, stalled lifts, bodyweight delta) plus the last week
4645
+ // of score_snapshots rows from the DB, build the row that backs the iOS
4646
+ // digest card. The card is glanceable and rule-based — no LLM call here.
4647
+ //
4648
+ // scoreSnapshots argument: array of { snapshotAt, score, components } rows,
4649
+ // ordered DESC by snapshotAt (i.e. listScoreSnapshots default ordering).
4650
+ export function buildWeeklyScoreDigest(weeklyContext, scoreSnapshots) {
4651
+ if (!weeklyContext) return null;
4652
+ const rows = Array.isArray(scoreSnapshots) ? scoreSnapshots : [];
4653
+ if (rows.length === 0) return null;
4654
+
4655
+ const weekStart = weeklyContext.weekRangeIso?.start;
4656
+ const weekEnd = weeklyContext.weekRangeIso?.end;
4657
+ if (!weekStart || !weekEnd) return null;
4658
+
4659
+ const inWeek = (iso) => {
4660
+ const d = String(iso ?? '').slice(0, 10);
4661
+ return d >= weekStart && d <= weekEnd;
4662
+ };
4663
+
4664
+ const latestInWeek = rows.find((r) => inWeek(r.snapshotAt));
4665
+ if (!latestInWeek) return null;
4666
+
4667
+ // Earliest prior row to compute a delta against. "Prior" means strictly
4668
+ // before the start of the digest week, capped at the most recent such row.
4669
+ const priorRow = rows.find((r) => String(r.snapshotAt ?? '').slice(0, 10) < weekStart);
4670
+
4671
+ const components = latestInWeek.components ?? {};
4672
+ const priorComponents = priorRow?.components ?? {};
4673
+ const componentsDelta = {};
4674
+ const keys = new Set([...Object.keys(components), ...Object.keys(priorComponents)]);
4675
+ for (const key of keys) {
4676
+ const now = scoreComponentNumber(components[key]);
4677
+ const before = scoreComponentNumber(priorComponents[key]);
4678
+ if (Number.isFinite(now) && Number.isFinite(before)) {
4679
+ componentsDelta[key] = Math.round((now - before) * 10) / 10;
4680
+ }
4681
+ }
4682
+
4683
+ const scoreDelta = priorRow && Number.isFinite(Number(priorRow.score))
4684
+ ? Math.round(Number(latestInWeek.score) - Number(priorRow.score))
4685
+ : null;
4686
+
4687
+ const signals = {
4688
+ sessionCount: weeklyContext.sessionCount ?? 0,
4689
+ totalVolume: weeklyContext.totalVolume ?? 0,
4690
+ adherencePct: weeklyContext.adherencePct ?? null,
4691
+ prCount: Array.isArray(weeklyContext.prsThisWeek) ? weeklyContext.prsThisWeek.length : 0,
4692
+ topPr: Array.isArray(weeklyContext.prsThisWeek) && weeklyContext.prsThisWeek.length > 0
4693
+ ? weeklyContext.prsThisWeek[0]
4694
+ : null,
4695
+ topStalledExercise: Array.isArray(weeklyContext.stalledExercises) && weeklyContext.stalledExercises.length > 0
4696
+ ? weeklyContext.stalledExercises[0].exerciseName
4697
+ : null,
4698
+ bodyweightDeltaKg: weeklyContext.bodyweightDeltaKg ?? null,
4699
+ };
4700
+
4701
+ return {
4702
+ weekStart,
4703
+ weekEnd,
4704
+ score: Math.round(Number(latestInWeek.score)),
4705
+ scoreDelta,
4706
+ components,
4707
+ componentsDelta,
4708
+ signals,
4709
+ observation: weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }),
4710
+ sourceSnapshotId: latestInWeek.id != null ? String(latestInWeek.id) : null,
4711
+ };
4712
+ }
4713
+
4714
+ // Rule-based observation. Picks the single most useful sentence for the card.
4715
+ // Order: no sessions logged > biggest negative component drop > top PR >
4716
+ // stalled lift > positive consistency. Templated, never references plan rituals.
4717
+ export function weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }) {
4718
+ const sessionCount = signals?.sessionCount ?? 0;
4719
+ if (sessionCount === 0) {
4720
+ return 'No sessions logged this week.';
4721
+ }
4722
+
4723
+ let worstKey = null;
4724
+ let worstValue = 0;
4725
+ for (const [key, value] of Object.entries(componentsDelta ?? {})) {
4726
+ if (Number.isFinite(value) && value < worstValue) {
4727
+ worstKey = key;
4728
+ worstValue = value;
4729
+ }
4730
+ }
4731
+ if (worstKey && worstValue <= -2) {
4732
+ return `Your ${worstKey} score dropped ${Math.abs(Math.round(worstValue))} this week — biggest drag on your overall score.`;
4733
+ }
4734
+
4735
+ if (signals.topPr?.exerciseName) {
4736
+ const weight = Number(signals.topPr.weight);
4737
+ const reps = Number(signals.topPr.reps);
4738
+ const repStr = Number.isFinite(weight) && Number.isFinite(reps) && weight > 0 && reps > 0
4739
+ ? ` (${weight}×${reps})`
4740
+ : '';
4741
+ return `${signals.topPr.exerciseName} PR this week${repStr}.`;
4742
+ }
4743
+
4744
+ if (signals.topStalledExercise) {
4745
+ return `${signals.topStalledExercise} hasn't moved in a few weeks — the lift dragging your trajectory most.`;
4746
+ }
4747
+
4748
+ if (Number.isFinite(scoreDelta) && scoreDelta >= 3) {
4749
+ return `Score up ${scoreDelta} from last week — keep the rhythm.`;
4750
+ }
4751
+
4752
+ return `${sessionCount} session${sessionCount === 1 ? '' : 's'} logged this week.`;
4753
+ }
package/src/remote.js CHANGED
@@ -4,8 +4,8 @@ import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand }
4
4
  import { resolveServiceUrl } from './service-url.js';
5
5
 
6
6
  function notImplementedError() {
7
- const error = new Error('Remote read mode is not implemented yet. Use --input or INCREMNT_SNAPSHOT for now.');
8
- error.code = 'REMOTE_NOT_IMPLEMENTED';
7
+ const error = new Error('No transport configured for this session. Run `incremnt login` to authenticate, or pass --input <file> / set INCREMNT_SNAPSHOT to use a local snapshot. If login keeps failing with a contract-mismatch error, run `npm install -g incremnt@latest` to upgrade the CLI.');
8
+ error.code = 'NO_TRANSPORT_CONFIGURED';
9
9
  return error;
10
10
  }
11
11
 
@@ -733,6 +733,45 @@ function hasFatigueLanguage(output) {
733
733
  return /\b(fatigue|fatigued|underrecovered|recovery debt|fatigue ceiling|limited by recovery|limited by fatigue|accumulated fatigue)\b/i.test(output);
734
734
  }
735
735
 
736
+ function hasAskFatigueRecoveryLanguage(output) {
737
+ return hasFatigueLanguage(output)
738
+ || /\b(?:poor|low|bad|incomplete)\s+recovery\b/i.test(output)
739
+ || /\bunder[-\s]?recovery\b/i.test(output)
740
+ || /\brecovery\s+(?:limited|held back|caused|explains|drove|deficit|issue|problem)\b/i.test(output);
741
+ }
742
+
743
+ function hasAskFatigueRecoveryUncertaintyLanguage(output) {
744
+ const missingRecoveryData = /\b(?:no|not enough|without|missing|lack(?:ing)?|insufficient)\s+(?:\w+\s+){0,4}?(?:recovery|readiness|vitals?|sleep|hrv|heart rate|data|info|signals?|metrics?)\b/i.test(output);
745
+ const refusesInference = /\b(?:cannot|can't|do not|don't|does not|doesn't|would not|wouldn't|not enough|isn't enough|is not enough|no basis to|hard to)\s+(?:\w+\s+){0,12}?(?:infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\s+(?:\w+\s+){0,12}?(?:fatigue|recovery|readiness|why)\b/i.test(output);
746
+ const recoveryDoesNotExplain = /\b(?:fatigue|recovery|readiness)\b\s+(?:\w+\s+){0,10}?(?:cannot|can't|does not|doesn't|would not|wouldn't|isn't|is not)\s+(?:\w+\s+){0,10}?(?:explain|prove|show|tell|account for)\b/i.test(output);
747
+ return missingRecoveryData || refusesInference || recoveryDoesNotExplain;
748
+ }
749
+
750
+ function hasAskPositiveFatigueRecoveryAttribution(output) {
751
+ const concept = String.raw`(?:fatigue|fatigued|under[-\s]?recovered|under[-\s]?recovery|poor recovery|low recovery|incomplete recovery|recovery debt|fatigue ceiling|accumulated fatigue)`;
752
+ const causeVerb = String.raw`(?:because|due to|caused by|from|reflects?|suggests?|indicates?|points? to|explains?|limited|held back|drove|contributed to|tied to|tie\s+\w+\s+to)`;
753
+ const patterns = [
754
+ new RegExp(String.raw`\b${causeVerb}\b.{0,80}\b${concept}\b`, 'gi'),
755
+ new RegExp(String.raw`\b${concept}\b.{0,80}\b(?:caused|limited|held back|explains?|drove|led to|contributed to|accounts? for)\b`, 'gi')
756
+ ];
757
+ for (const pattern of patterns) {
758
+ for (const match of output.matchAll(pattern)) {
759
+ const start = Math.max(0, (match.index ?? 0) - 40);
760
+ const window = output.slice(start, (match.index ?? 0) + match[0].length);
761
+ if (!/\b(?:not|no|cannot|can't|doesn't|does not|would not|wouldn't|isn't|is not)\b/i.test(window)) {
762
+ return true;
763
+ }
764
+ }
765
+ }
766
+ return false;
767
+ }
768
+
769
+ function hasUnsupportedAskFatigueRecoveryClaim(output) {
770
+ if (!hasAskFatigueRecoveryLanguage(output)) return false;
771
+ if (hasAskPositiveFatigueRecoveryAttribution(output)) return true;
772
+ return !hasAskFatigueRecoveryUncertaintyLanguage(output);
773
+ }
774
+
736
775
  function matchesHistoricalFamilyName(claimName, actualName) {
737
776
  const claimVariants = new Set(historicalExerciseVariants(claimName));
738
777
  const actualVariants = new Set(historicalExerciseVariants(actualName));
@@ -1182,7 +1221,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
1182
1221
  }
1183
1222
  }
1184
1223
 
1185
- if (hasFatigueLanguage(normalized) && !hasAskFatigueSupport(snapshot)) {
1224
+ if (hasUnsupportedAskFatigueRecoveryClaim(normalized) && !hasAskFatigueSupport(snapshot)) {
1186
1225
  failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
1187
1226
  }
1188
1227
 
@@ -21,6 +21,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
21
21
  'weekly-checkin-current': 30,
22
22
  'weekly-checkin-ack': 30,
23
23
  'weekly-checkin-start': 10,
24
+ 'weekly-digest-current': 30,
24
25
  'dev-login': 10,
25
26
  'device-start': 20,
26
27
  'device-poll': 300,
@@ -1050,6 +1051,10 @@ function routeRequest(url, method) {
1050
1051
  return { command: 'weekly-checkin-start', options: {} };
1051
1052
  }
1052
1053
 
1054
+ if (pathname === '/cli/weekly-digest/current') {
1055
+ return { command: 'weekly-digest-current', options: {} };
1056
+ }
1057
+
1053
1058
  if (pathname === '/cli/health/ai') {
1054
1059
  return {
1055
1060
  command: 'health-ai',
@@ -1434,41 +1439,6 @@ function routeRequest(url, method) {
1434
1439
  return null;
1435
1440
  }
1436
1441
 
1437
- /// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
1438
- /// a short text prelude prepended to the AI context. Without this the model
1439
- /// would have to infer "is this a deload week / was last week deload?" from
1440
- /// session prose; with it the structured phase facts are explicit.
1441
- function formatProgramPhasePrelude(programPhase) {
1442
- if (!programPhase || typeof programPhase !== 'object') return null;
1443
- const current = programPhase.current;
1444
- const previous = programPhase.previousWeek;
1445
- const next = programPhase.nextWeek;
1446
- if (!current?.phase || typeof current.displayWeek !== 'number') return null;
1447
- const describe = (phase) => {
1448
- if (!phase?.phase) return null;
1449
- const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
1450
- return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
1451
- };
1452
- const describeList = (phases) => {
1453
- if (!Array.isArray(phases) || phases.length === 0) return null;
1454
- return phases.map(describe).filter(Boolean).join(', ');
1455
- };
1456
- const lines = [
1457
- '[Program phase]',
1458
- `- Current: ${describe(current)}`
1459
- ];
1460
- if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
1461
- if (next?.phase) lines.push(`- Next: ${describe(next)}`);
1462
- if (programPhase.isPostDeloadReturn === true) {
1463
- lines.push('- Post-deload return: yes (last week was deload, this week is build)');
1464
- }
1465
- const range = describeList(programPhase.phasesInRange);
1466
- if (range) lines.push(`- Range phases: ${range}`);
1467
- const previousRange = describeList(programPhase.previousRangePhases);
1468
- if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
1469
- return lines.join('\n');
1470
- }
1471
-
1472
1442
  export function formatIncrementScorePrelude(snapshots) {
1473
1443
  if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1474
1444
  const latest = snapshots[0];
@@ -2021,6 +1991,7 @@ export function createSyncServiceRequestHandler({
2021
1991
  pushMobileSyncChangesForAccount = null,
2022
1992
  insertScoreSnapshotsForAccount = null,
2023
1993
  listScoreSnapshotsForAccount = null,
1994
+ getCurrentWeeklyScoreDigestForAccount = null,
2024
1995
  // Social
2025
1996
  social = null,
2026
1997
  onError = null
@@ -3488,6 +3459,43 @@ export function createSyncServiceRequestHandler({
3488
3459
  return;
3489
3460
  }
3490
3461
 
3462
+ if (route.command === 'weekly-digest-current') {
3463
+ if (request.method !== 'GET') {
3464
+ methodNotAllowed(response, 'Use GET for /cli/weekly-digest/current.');
3465
+ return;
3466
+ }
3467
+ if (!getCurrentWeeklyScoreDigestForAccount) {
3468
+ json(response, 503, { error: 'Weekly digest not available' });
3469
+ return;
3470
+ }
3471
+ try {
3472
+ const row = await getCurrentWeeklyScoreDigestForAccount(account);
3473
+ if (!row) {
3474
+ response.writeHead(204);
3475
+ response.end();
3476
+ return;
3477
+ }
3478
+ json(response, 200, {
3479
+ id: row.id,
3480
+ weekStartDate: row.weekStartDate,
3481
+ status: row.status,
3482
+ score: row.score,
3483
+ scoreDelta: row.scoreDelta,
3484
+ components: row.components,
3485
+ componentsDelta: row.componentsDelta,
3486
+ signals: row.signals,
3487
+ observation: row.observation,
3488
+ formulaVersion: row.formulaVersion,
3489
+ generatedAt: row.generatedAt,
3490
+ seenAt: row.seenAt,
3491
+ });
3492
+ } catch (err) {
3493
+ console.error('Weekly digest read error:', err.message);
3494
+ json(response, 500, { error: 'Failed to read weekly digest' });
3495
+ }
3496
+ return;
3497
+ }
3498
+
3491
3499
  let snapshot;
3492
3500
  try {
3493
3501
  snapshot = loadSnapshotForAccount
@@ -3707,7 +3715,10 @@ export function createSyncServiceRequestHandler({
3707
3715
  if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3708
3716
  try {
3709
3717
  const { weeklyCheckinContext } = await import('./queries.js');
3710
- const ctx = weeklyCheckinContext(snapshot, account.id, {});
3718
+ // Anchor to row.weekStartDate so lazy-gen describes the
3719
+ // canonical week, matching cron behaviour (onemore-8oc5).
3720
+ const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
3721
+ const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
3711
3722
  if (ctx) {
3712
3723
  let priorCommitmentRow = null;
3713
3724
  if (listActiveCoachCommitmentsForAccount) {
@@ -4160,13 +4171,9 @@ export function createSyncServiceRequestHandler({
4160
4171
  ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4161
4172
  : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4162
4173
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4163
- const serverProgramPhase = persistedKind === 'weekly-checkin'
4164
- ? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
4165
- : null;
4166
- const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
4167
4174
  const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
4168
4175
 
4169
- const preludes = [programPhasePrelude, incrementScorePrelude].filter(Boolean);
4176
+ const preludes = [incrementScorePrelude].filter(Boolean);
4170
4177
  const ctx = preludes.length > 0
4171
4178
  ? `${preludes.join('\n\n')}\n\n${routedContext.context}`
4172
4179
  : routedContext.context;