incremnt 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -98,6 +98,23 @@ export const commandSchema = [
98
98
  options: [
99
99
  { name: 'id', type: 'string', required: true, description: 'Plan ID' }
100
100
  ]
101
+ },
102
+ {
103
+ command: 'cycles list',
104
+ id: 'cycle-summary-list',
105
+ description: 'List cycle summaries',
106
+ options: [
107
+ { name: 'program-id', type: 'string', required: false, description: 'Filter by program ID' }
108
+ ]
109
+ },
110
+ {
111
+ command: 'cycles show',
112
+ id: 'cycle-summary-show',
113
+ description: 'Show cycle summary details',
114
+ usage: 'cycles show --id <summary-id>',
115
+ options: [
116
+ { name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
117
+ ]
101
118
  }
102
119
  ];
103
120
 
package/src/format.js CHANGED
@@ -145,6 +145,14 @@ function formatSessionShow(payload) {
145
145
  lines.push(keyValue('Calories', `${payload.activeCalories} kcal`));
146
146
  }
147
147
 
148
+ if (payload.aiCoachNotes) {
149
+ lines.push('');
150
+ lines.push(` ${chalk.bold('AI Summary')}`);
151
+ for (const line of payload.aiCoachNotes.split('\n')) {
152
+ lines.push(` ${line}`);
153
+ }
154
+ }
155
+
148
156
  return lines.join('\n');
149
157
  }
150
158
 
@@ -403,6 +411,58 @@ function formatProposalsList(payload) {
403
411
  return lines.join('\n');
404
412
  }
405
413
 
414
+ function formatCycleSummaryList(payload) {
415
+ if (!Array.isArray(payload) || payload.length === 0) {
416
+ return 'No cycle summaries found.';
417
+ }
418
+
419
+ const lines = [header('CYCLE SUMMARIES'), ''];
420
+
421
+ for (const summary of payload) {
422
+ const date = formatShortDate(summary.completedDate);
423
+ const sets = `${summary.totalSetsCompleted}/${summary.totalSetsPlanned} sets`;
424
+ const progressions = summary.progressionCount > 0 ? `${summary.progressionCount} progressions` : '';
425
+ const ai = summary.hasAISummary ? chalk.green('AI') : '';
426
+ const parts = [chalk.dim(sets), progressions ? chalk.dim(progressions) : '', ai].filter(Boolean);
427
+
428
+ lines.push(` ${chalk.bold(date)} ${summary.programName}${dimDot()}${parts.join(dimDot())}`);
429
+ }
430
+
431
+ return lines.join('\n');
432
+ }
433
+
434
+ function formatCycleSummaryShow(payload) {
435
+ if (!payload) {
436
+ return 'Cycle summary not found.';
437
+ }
438
+
439
+ const date = formatDayAndDate(payload.completedDate);
440
+ const lines = [` ${chalk.bold('CYCLE SUMMARY')}${dimDot()}${date}`, ''];
441
+
442
+ lines.push(keyValue('Program', payload.programName));
443
+ lines.push(keyValue('Sets', `${payload.totalSetsCompleted}/${payload.totalSetsPlanned}`));
444
+
445
+ if (payload.progressionUpdates.length > 0) {
446
+ lines.push('');
447
+ lines.push(` ${chalk.bold('Progressions')}`);
448
+
449
+ for (const update of payload.progressionUpdates) {
450
+ const icon = update.kind === 'deload' ? chalk.yellow('\u2193') : chalk.green('\u2191');
451
+ lines.push(` ${icon} ${update.exerciseName.padEnd(24)} ${chalk.dim(update.change)}`);
452
+ }
453
+ }
454
+
455
+ if (payload.aiSummary) {
456
+ lines.push('');
457
+ lines.push(` ${chalk.bold('AI Summary')}`);
458
+ for (const line of payload.aiSummary.split('\n')) {
459
+ lines.push(` ${line}`);
460
+ }
461
+ }
462
+
463
+ return lines.join('\n');
464
+ }
465
+
406
466
  function formatProposalDismissed(payload) {
407
467
  if (!payload) return 'Proposal not found.';
408
468
  return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
@@ -410,11 +470,16 @@ function formatProposalDismissed(payload) {
410
470
 
411
471
  // --- Main export ---
412
472
 
413
- export function formatHelp() {
473
+ export function formatHelp(opts = {}) {
414
474
  const cmd = (name, desc) => ` ${chalk.bold(name.padEnd(38))} ${chalk.dim(desc)}`;
475
+ const authBadge = opts.isAuthenticated
476
+ ? chalk.green('\u25cf Authenticated')
477
+ : chalk.yellow("\u25cf Authentication required - run 'incremnt login'");
478
+
415
479
  const lines = [
416
480
  '',
417
481
  header('INCREMNT') + dimDot() + chalk.dim('strength tracking CLI'),
482
+ ` ${authBadge}`,
418
483
  '',
419
484
  header('USAGE'),
420
485
  ` incremnt <command> [options]`,
@@ -459,6 +524,8 @@ export function formatPretty(command, payload) {
459
524
  'program-detail': formatProgramDetail,
460
525
  'planned-vs-actual': formatPlannedVsActual,
461
526
  'why-did-this-change': formatWhyDidThisChange,
527
+ 'cycle-summary-list': formatCycleSummaryList,
528
+ 'cycle-summary-show': formatCycleSummaryShow,
462
529
  'programs-propose': formatProposalCreated,
463
530
  'programs-proposals': formatProposalsList,
464
531
  'proposal-dismiss': formatProposalDismissed
package/src/lib.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { clearSessionState, isSessionExpired, readSessionState, resolveConfigDir } from './state.js';
13
13
  import { createTransport } from './transport.js';
14
14
  import { formatPretty, formatHelp } from './format.js';
15
+ import { printLogo } from './logo.js';
15
16
 
16
17
  function parseArgs(argv) {
17
18
  const commandTokens = [];
@@ -66,12 +67,15 @@ export async function runCli(argv, stdout, stderr) {
66
67
  dismiss: 'proposal-dismiss'
67
68
  })[command] ?? command;
68
69
 
70
+ const sessionState = await readSessionState();
71
+ const isAuthenticated = Boolean(sessionState?.session && !isSessionExpired(sessionState.session));
72
+
69
73
  if (!command || options.help) {
70
- stdout.write(`${formatHelp()}\n`);
74
+ printLogo(stdout);
75
+ stdout.write(`${formatHelp({ isAuthenticated })}\n`);
71
76
  return 0;
72
77
  }
73
78
 
74
- const sessionState = await readSessionState();
75
79
  const transport = await createTransport(options, sessionState);
76
80
 
77
81
  if (normalizedCommand === 'status') {
package/src/logo.js ADDED
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+
3
+ const startColor = { r: 0, g: 255, b: 163 }; // #00ffa3
4
+ const endColor = { r: 59, g: 130, b: 246 }; // #3b82f6
5
+
6
+ function interpolateColor(start, end, factor) {
7
+ const r = Math.round(start.r + (end.r - start.r) * factor);
8
+ const g = Math.round(start.g + (end.g - start.g) * factor);
9
+ const b = Math.round(start.b + (end.b - start.b) * factor);
10
+ return { r, g, b };
11
+ }
12
+
13
+ // Extracted from figlet 'ANSI Regular' output for 'INCREMNT'
14
+ // We use '#' in source so the ASCII art stays ASCII-only and easy to edit,
15
+ // then render as '█' block characters at runtime.
16
+ const rawLines = [
17
+ '## ### ## ###### ###### ####### ### ### ### ## ######## ',
18
+ '## #### ## ## ## ## ## #### #### #### ## ## ',
19
+ '## ## ## ## ## ###### ##### ## #### ## ## ## ## ## ',
20
+ '## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ',
21
+ '## ## #### ###### ## ## ####### ## ## ## #### ## '
22
+ ];
23
+
24
+ // 3D Drop Shadow offset exactly like Gemini CLI
25
+ const SHADOW_OFFSET_X = 1;
26
+ const SHADOW_OFFSET_Y = 1;
27
+
28
+ const totalLines = 5 + SHADOW_OFFSET_Y;
29
+ const lineLength = rawLines[0].length + SHADOW_OFFSET_X;
30
+
31
+ export function printLogo(stdout = process.stdout) {
32
+ // Gradient starts at the 'M' character position
33
+ // I: 0, N: 3, C: 14, R: 21, E: 29, M: 37
34
+ const gradientStartIdx = 37;
35
+
36
+ stdout.write('\n');
37
+ for (let r = 0; r < totalLines; r++) {
38
+ let lineStr = '';
39
+
40
+ for (let c = 0; c < lineLength; c++) {
41
+ const fgY = r;
42
+ const fgX = c;
43
+ const hasFg = (fgY >= 0 && fgY < 5 && fgX >= 0 && fgX < rawLines[0].length && rawLines[fgY][fgX] === '#');
44
+
45
+ const bgY = r - SHADOW_OFFSET_Y;
46
+ const bgX = c - SHADOW_OFFSET_X;
47
+ const hasBg = (bgY >= 0 && bgY < 5 && bgX >= 0 && bgX < rawLines[0].length && rawLines[bgY][bgX] === '#');
48
+
49
+ if (hasFg) {
50
+ if (fgX < gradientStartIdx) {
51
+ lineStr += chalk.white('█');
52
+ } else {
53
+ const factor = Math.max(0, Math.min(1, (fgX - gradientStartIdx) / (rawLines[0].length - gradientStartIdx - 2)));
54
+ const col = interpolateColor(startColor, endColor, factor);
55
+ lineStr += chalk.rgb(col.r, col.g, col.b)('█');
56
+ }
57
+ } else if (hasBg) {
58
+ if (bgX < gradientStartIdx) {
59
+ lineStr += chalk.rgb(55, 55, 65)('█');
60
+ } else {
61
+ const factor = Math.max(0, Math.min(1, (bgX - gradientStartIdx) / (rawLines[0].length - gradientStartIdx - 2)));
62
+ const col = interpolateColor(startColor, endColor, factor);
63
+ const shadowFactor = 0.35;
64
+ lineStr += chalk.rgb(Math.round(col.r * shadowFactor), Math.round(col.g * shadowFactor), Math.round(col.b * shadowFactor))('█');
65
+ }
66
+ } else {
67
+ lineStr += ' ';
68
+ }
69
+ }
70
+ stdout.write(lineStr + '\n');
71
+ }
72
+ stdout.write('\n');
73
+ }
package/src/openrouter.js CHANGED
@@ -1,17 +1,26 @@
1
1
  const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
2
2
  const DEFAULT_TIMEOUT_MS = 15_000;
3
- const DEFAULT_MAX_TOKENS = 300;
3
+ const DEFAULT_MAX_TOKENS = 500;
4
4
 
5
- const SYSTEM_PROMPT = `You are a strength coach writing a brief note after reviewing a trainee's week of training. Write 2-3 short paragraphs separated by blank lines.
5
+ const CYCLE_SUMMARY_PROMPT = `You are a strength coach reviewing a trainee's completed training cycle (typically one week). Write 3-4 short paragraphs separated by blank lines.
6
+
7
+ Your job is to give a cycle-level review — not a session-by-session recap. The app already shows set completion rate, individual session breakdowns, and deload adjustments — do NOT repeat any of that. Synthesize across the cycle.
8
+
9
+ Cover these in order of relevance (skip any that don't apply):
10
+ 1. Overall cycle assessment: was this a build/deload/peak week? Did volume and intensity match the intent? If it was a deload, don't flag low numbers as a problem.
11
+ 2. Progression commentary: the app made auto-progression decisions listed below. Comment on whether they look right given the data.
12
+ 3. Multi-cycle trends: if previous cycle data is provided, note meaningful trends. Don't force trends where there aren't any.
13
+ 4. Goal progress: if the trainee has strength goals, comment on trajectory.
14
+ 5. One concrete thing to watch or change next cycle. Be specific.
6
15
 
7
16
  Rules:
8
- - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
17
+ - Only state what the data shows. Never claim how something "felt."
18
+ - Reference specific exercises, weights, and reps. Use numbers, not vague praise.
9
19
  - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
10
- - Note what went well and what lagged, with specifics.
11
- - End with one concrete suggestion for next week. Be specific about which exercise and what to change.
12
- - Write like a real person texting a training partner short sentences, no filler, no cheerleading.
13
- - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
14
- - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
20
+ - If exercises were swapped from the plan, note the pattern and ask about it if recurring.
21
+ - Write like a training partner looking at a logbook, not a motivational coach. Short sentences, no filler, no cheerleading. Questions are good.
22
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting, smooth, controlled.
23
+ - Never use -ing clauses that add fake depth.
15
24
  - No bullet points or lists.`;
16
25
 
17
26
  export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
@@ -35,7 +44,7 @@ export async function generateCoachingSummary(cycleContext, { apiKey, model, tim
35
44
  body: JSON.stringify({
36
45
  model: resolvedModel,
37
46
  messages: [
38
- { role: 'system', content: SYSTEM_PROMPT },
47
+ { role: 'system', content: CYCLE_SUMMARY_PROMPT },
39
48
  { role: 'user', content: userContent }
40
49
  ],
41
50
  max_tokens: DEFAULT_MAX_TOKENS,
@@ -61,15 +70,30 @@ export async function generateCoachingSummary(cycleContext, { apiKey, model, tim
61
70
  }
62
71
  }
63
72
 
64
- function formatCycleContext(ctx) {
73
+ export function formatCycleContext(ctx) {
74
+ const intentLabel = ctx.cycleIntent ? ` (${ctx.cycleIntent})` : '';
65
75
  const lines = [
66
- `Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
76
+ `Program: ${ctx.programName}, Week ${ctx.cycleNumber}${intentLabel}, ${ctx.totalSessions} session(s).`
67
77
  ];
68
78
 
79
+ if (ctx.setCompletionRate) {
80
+ const pct = ctx.setCompletionRate.planned > 0
81
+ ? Math.round(ctx.setCompletionRate.completed / ctx.setCompletionRate.planned * 100)
82
+ : 0;
83
+ lines.push(`Completion: ${ctx.setCompletionRate.completed}/${ctx.setCompletionRate.planned} sets (${pct}%).`);
84
+ }
85
+
86
+ if (ctx.adaptationNote) {
87
+ lines.push(`Adaptation: ${ctx.adaptationNote}`);
88
+ }
89
+
90
+ lines.push('');
91
+ lines.push('Sessions:');
69
92
  for (const session of ctx.sessions) {
70
93
  const parts = [session.dayName || 'Session'];
71
94
  if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
72
95
  if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
96
+ if (session.averageHeartRate) parts.push(`avg HR ${session.averageHeartRate}`);
73
97
  lines.push(parts.join(', '));
74
98
 
75
99
  for (const ex of session.exercises) {
@@ -84,32 +108,80 @@ function formatCycleContext(ctx) {
84
108
  }
85
109
 
86
110
  if (ctx.prsThisCycle.length > 0) {
87
- lines.push('PRs this week:');
111
+ lines.push('');
112
+ lines.push('PRs this cycle:');
88
113
  for (const pr of ctx.prsThisCycle) {
89
114
  lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
90
115
  }
91
116
  }
92
117
 
118
+ if (ctx.progressionDecisions?.length > 0) {
119
+ lines.push('');
120
+ lines.push('Progression decisions made by the app:');
121
+ for (const pd of ctx.progressionDecisions) {
122
+ const detail = pd.detail ? ` (${pd.detail})` : '';
123
+ const kind = pd.kind ? `[${pd.kind}] ` : '';
124
+ lines.push(` ${pd.exerciseName}: ${kind}${pd.action}${detail}`);
125
+ }
126
+ }
127
+
128
+ if (ctx.swapPatterns?.length > 0) {
129
+ lines.push('');
130
+ lines.push('Exercise swaps:');
131
+ for (const sp of ctx.swapPatterns) {
132
+ lines.push(` ${sp.original} → ${sp.replacement} (${sp.count} of ${ctx.totalSessions} sessions)`);
133
+ }
134
+ }
135
+
93
136
  if (ctx.goalProgress) {
137
+ lines.push('');
94
138
  lines.push('Goal progress:');
95
139
  for (const g of ctx.goalProgress) {
96
140
  lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
97
141
  }
98
142
  }
99
143
 
144
+ if (ctx.previousCycles?.length > 0) {
145
+ lines.push('');
146
+ lines.push('Previous cycles:');
147
+ for (const pc of ctx.previousCycles) {
148
+ const summaryLine = pc.previousAISummary
149
+ ? `\n Coach noted: "${pc.previousAISummary.split('\n')[0].slice(0, 120)}"`
150
+ : '';
151
+ lines.push(` Week ${pc.weekNumber}: ${pc.sessionCount} sessions, ${pc.totalVolume} kg total volume${summaryLine}`);
152
+ }
153
+ }
154
+
155
+ if (ctx.exerciseTrends?.length > 0) {
156
+ lines.push('');
157
+ lines.push('Exercise trends (last 3 cycles):');
158
+ for (const et of ctx.exerciseTrends) {
159
+ const trendStr = et.trend.map((t) => t.e1RM).join(' → ');
160
+ lines.push(` ${et.exerciseName} e1RM: ${trendStr}`);
161
+ }
162
+ }
163
+
100
164
  return lines.join('\n');
101
165
  }
102
166
 
103
- export const WORKOUT_COACH_PROMPT = `You are a strength coach writing a brief note after reviewing one training session. Write 2-3 short paragraphs separated by blank lines.
167
+ export const WORKOUT_COACH_PROMPT = `You are reviewing a training session log. Write 2-3 short paragraphs separated by blank lines.
168
+
169
+ Your job is to surface things the user wouldn't notice from glancing at their workout summary. The app already shows them PRs, total volume, effort score, and exercise breakdown — do NOT repeat any of that.
170
+
171
+ Focus on:
172
+ - Plan deviations: exercises swapped, skipped, or added vs the plan. Ask why if something looks unusual.
173
+ - Set completion: if they did fewer sets than planned on an exercise, note it and ask about it.
174
+ - Cross-session patterns: trends across recent sessions (volume direction on specific lifts, consistent cutoffs, same weight for weeks).
175
+ - Ask 1-2 genuine questions about choices that look interesting or unusual.
104
176
 
105
177
  Rules:
106
- - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
107
- - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
108
- - Compare volume or effort to recent sessions if the data is there. Just state what changed.
109
- - End with one concrete suggestion for next session. Be specific about which exercise and what to try.
110
- - Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
111
- - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
178
+ - Only state what the data shows. Never claim how something "felt" — you have numbers, not feelings.
179
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, smooth, controlled.
112
180
  - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
181
+ - Never say things "felt smooth", "felt controlled", "feels about average" — you cannot know this.
182
+ - Never restate PRs, total volume, or effort score — the user already sees these in the app.
183
+ - Write like a training partner looking at a logbook, not a motivational coach.
184
+ - Short sentences, no filler, no cheerleading. Questions are good.
113
185
  - No bullet points or lists.`;
114
186
 
115
187
  export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
@@ -189,5 +261,24 @@ export function formatWorkoutContext(ctx) {
189
261
  }
190
262
  }
191
263
 
264
+ if (ctx.planComparison) {
265
+ const planLines = [];
266
+ if (ctx.planComparison.skipped.length > 0) {
267
+ planLines.push(` Skipped: ${ctx.planComparison.skipped.join(', ')}`);
268
+ }
269
+ if (ctx.planComparison.added.length > 0) {
270
+ planLines.push(` Added: ${ctx.planComparison.added.join(', ')}`);
271
+ }
272
+ for (const sc of ctx.planComparison.setsComparison) {
273
+ if (sc.completed !== sc.planned) {
274
+ planLines.push(` ${sc.exercise}: ${sc.completed}/${sc.planned} sets`);
275
+ }
276
+ }
277
+ if (planLines.length > 0) {
278
+ lines.push('Plan comparison:');
279
+ lines.push(...planLines);
280
+ }
281
+ }
282
+
192
283
  return lines.join('\n');
193
284
  }
package/src/queries.js CHANGED
@@ -2,6 +2,44 @@ function completionDateForSession(session) {
2
2
  return session.completedAt ?? session.summary?.date ?? session.date;
3
3
  }
4
4
 
5
+ function buildPlanComparison(session, performedExercises, plannedExercises) {
6
+ if (!Array.isArray(plannedExercises) || plannedExercises.length === 0) {
7
+ return undefined;
8
+ }
9
+
10
+ const plannedNames = plannedExercises.map((exercise) =>
11
+ normalizeExerciseName(exercise.name ?? exercise.exerciseName)
12
+ );
13
+ const performedNames = performedExercises.map((exercise) =>
14
+ normalizeExerciseName(exercise.exerciseName)
15
+ );
16
+
17
+ const skipped = plannedExercises
18
+ .filter((exercise) => !performedNames.includes(normalizeExerciseName(exercise.name ?? exercise.exerciseName)))
19
+ .map((exercise) => exercise.name ?? exercise.exerciseName);
20
+
21
+ const added = (session.exercises ?? [])
22
+ .filter((exercise) => !plannedNames.includes(normalizeExerciseName(exercise.name)))
23
+ .map((exercise) => exercise.name);
24
+
25
+ const setsComparison = plannedExercises
26
+ .filter((exercise) => performedNames.includes(normalizeExerciseName(exercise.name ?? exercise.exerciseName)))
27
+ .map((planned) => {
28
+ const plannedName = planned.name ?? planned.exerciseName;
29
+ const performed = (session.exercises ?? []).find(
30
+ (exercise) => normalizeExerciseName(exercise.name) === normalizeExerciseName(plannedName)
31
+ );
32
+ const completedSets = (performed?.sets ?? []).filter((set) => set.isComplete).length;
33
+ return {
34
+ exercise: plannedName,
35
+ planned: Array.isArray(planned.sets) ? planned.sets.length : (planned.targetSets ?? []).length,
36
+ completed: completedSets
37
+ };
38
+ });
39
+
40
+ return { skipped, added, setsComparison };
41
+ }
42
+
5
43
  function sessionSummary(session) {
6
44
  return {
7
45
  sessionId: session.id,
@@ -19,7 +57,8 @@ function sessionSummary(session) {
19
57
  exerciseCount: (session.exercises ?? []).length,
20
58
  recommendations: session.recommendations ?? {},
21
59
  historicalContext: session.historicalContext ?? null,
22
- prescriptionSnapshot: session.prescriptionSnapshot ?? null
60
+ prescriptionSnapshot: session.prescriptionSnapshot ?? null,
61
+ aiCoachNotes: session.aiCoachNotes ?? null
23
62
  };
24
63
  }
25
64
 
@@ -349,6 +388,43 @@ export function goalDetail(snapshot, planId) {
349
388
  };
350
389
  }
351
390
 
391
+ export function cycleSummaryList(snapshot, programId) {
392
+ const summaries = snapshot.cycleSummaries ?? [];
393
+ const filtered = programId
394
+ ? summaries.filter((s) => s.programId === programId)
395
+ : summaries;
396
+
397
+ return [...filtered]
398
+ .sort((a, b) => String(b.completedDate).localeCompare(String(a.completedDate)))
399
+ .map((s) => ({
400
+ id: s.id,
401
+ programId: s.programId,
402
+ programName: s.programName,
403
+ completedDate: s.completedDate,
404
+ totalSetsCompleted: s.totalSetsCompleted ?? 0,
405
+ totalSetsPlanned: s.totalSetsPlanned ?? 0,
406
+ progressionCount: (s.progressionUpdates ?? []).length,
407
+ hasAISummary: !!s.aiSummary
408
+ }));
409
+ }
410
+
411
+ export function cycleSummaryShow(snapshot, summaryId) {
412
+ const summaries = snapshot.cycleSummaries ?? [];
413
+ const summary = summaries.find((s) => s.id === summaryId);
414
+ if (!summary) return null;
415
+
416
+ return {
417
+ id: summary.id,
418
+ programId: summary.programId,
419
+ programName: summary.programName,
420
+ completedDate: summary.completedDate,
421
+ totalSetsCompleted: summary.totalSetsCompleted ?? 0,
422
+ totalSetsPlanned: summary.totalSetsPlanned ?? 0,
423
+ progressionUpdates: summary.progressionUpdates ?? [],
424
+ aiSummary: summary.aiSummary ?? null
425
+ };
426
+ }
427
+
352
428
  export function cycleSummaryContext(snapshot, programId) {
353
429
  const programs = snapshot.programs ?? [];
354
430
  const program = programId
@@ -376,6 +452,10 @@ export function cycleSummaryContext(snapshot, programId) {
376
452
 
377
453
  if (cycleSessions.length === 0) return null;
378
454
 
455
+ const firstSession = cycleSessions[0];
456
+ const cycleIntent = firstSession?.historicalContext?.programProgressionType ?? null;
457
+ const adaptationNote = firstSession?.historicalContext?.latestAdaptationSummary ?? null;
458
+
379
459
  const priorSessions = (snapshot.sessions ?? []).filter(
380
460
  (s) =>
381
461
  s.programId === program.id &&
@@ -452,6 +532,89 @@ export function cycleSummaryContext(snapshot, programId) {
452
532
  };
453
533
  });
454
534
 
535
+ // Group prior sessions by week number for multi-cycle data
536
+ const sessionsByWeek = new Map();
537
+ for (const s of priorSessions) {
538
+ const wk = s.historicalContext?.programWeekNumber;
539
+ if (wk == null) continue;
540
+ if (!sessionsByWeek.has(wk)) sessionsByWeek.set(wk, []);
541
+ sessionsByWeek.get(wk).push(s);
542
+ }
543
+
544
+ // Build previous cycle summaries (last 2 weeks before current, most recent first)
545
+ const priorWeeks = [...sessionsByWeek.keys()].sort((a, b) => b - a).slice(0, 2);
546
+ const cycleSummariesArr = snapshot.cycleSummaries ?? [];
547
+ const programCycleSummaries = cycleSummariesArr
548
+ .filter((cs) => cs.programId === program.id)
549
+ .sort((a, b) => String(b.completedDate).localeCompare(String(a.completedDate)));
550
+
551
+ const previousCycles = priorWeeks.map((wk, idx) => {
552
+ const weekSessions = sessionsByWeek.get(wk);
553
+ const totalVolume = weekSessions.reduce((sum, s) => sum + (s.summary?.totalVolume ?? s.volume ?? 0), 0);
554
+ const matchingCycleSummary = programCycleSummaries[idx] ?? null;
555
+
556
+ return {
557
+ weekNumber: wk,
558
+ sessionCount: weekSessions.length,
559
+ totalVolume: Math.round(totalVolume),
560
+ previousAISummary: matchingCycleSummary?.aiSummary ?? null
561
+ };
562
+ });
563
+
564
+ // Build exercise e1RM trends across last 3 cycles (including current)
565
+ const currentExerciseKeys = new Set();
566
+ const exerciseDisplayNames = new Map();
567
+ for (const s of cycleSessions) {
568
+ for (const ex of s.exercises ?? []) {
569
+ const key = normalizeExerciseName(ex.name);
570
+ currentExerciseKeys.add(key);
571
+ if (!exerciseDisplayNames.has(key)) exerciseDisplayNames.set(key, ex.name);
572
+ }
573
+ }
574
+
575
+ const allWeeks = [...new Set([...priorWeeks, cycleWeekNumber])].sort((a, b) => a - b);
576
+ const trendWeeks = allWeeks.slice(-3);
577
+ const allSessions = [...priorSessions, ...cycleSessions];
578
+
579
+ const exerciseTrends = [];
580
+ for (const exKey of currentExerciseKeys) {
581
+ const trend = [];
582
+ for (const wk of trendWeeks) {
583
+ let bestE1RM = 0;
584
+ for (const s of allSessions) {
585
+ if ((s.historicalContext?.programWeekNumber ?? 0) !== wk) continue;
586
+ for (const ex of s.exercises ?? []) {
587
+ if (normalizeExerciseName(ex.name) !== exKey) continue;
588
+ for (const set of ex.sets ?? []) {
589
+ if (!set.isComplete) continue;
590
+ const score = Number(set.weight) * (1 + Number(set.reps) / 30);
591
+ if (score > bestE1RM) bestE1RM = score;
592
+ }
593
+ }
594
+ }
595
+ if (bestE1RM > 0) {
596
+ trend.push({ week: wk, e1RM: Math.round(bestE1RM * 10) / 10 });
597
+ }
598
+ }
599
+ if (trend.length >= 2) {
600
+ exerciseTrends.push({ exerciseName: exerciseDisplayNames.get(exKey), trend });
601
+ }
602
+ }
603
+
604
+ const swapCounts = new Map();
605
+ for (const session of cycleSessions) {
606
+ for (const exercise of session.exercises ?? []) {
607
+ if (exercise.swappedFrom) {
608
+ const key = `${exercise.swappedFrom}\u2192${exercise.name}`;
609
+ swapCounts.set(key, (swapCounts.get(key) ?? 0) + 1);
610
+ }
611
+ }
612
+ }
613
+ const swapPatterns = [...swapCounts.entries()].map(([key, count]) => {
614
+ const [original, replacement] = key.split('\u2192');
615
+ return { original, replacement, count };
616
+ });
617
+
455
618
  let goalProgress = null;
456
619
  const plans = snapshot.strengthPlans ?? [];
457
620
  const activePlan = plans.find(
@@ -472,13 +635,33 @@ export function cycleSummaryContext(snapshot, programId) {
472
635
  });
473
636
  }
474
637
 
638
+ const matchingSummary = programCycleSummaries[0] ?? null;
639
+
640
+ const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
641
+ exerciseName: u.exerciseName,
642
+ action: u.action ?? u.change ?? null,
643
+ detail: u.detail ?? null,
644
+ kind: u.kind ?? null
645
+ }));
646
+
647
+ const setCompletionRate = matchingSummary
648
+ ? { completed: matchingSummary.totalSetsCompleted ?? 0, planned: matchingSummary.totalSetsPlanned ?? 0 }
649
+ : null;
650
+
475
651
  return {
476
652
  programName: program.name,
477
653
  cycleNumber: cycleWeekNumber,
478
654
  totalSessions: cycleSessions.length,
479
655
  sessions,
480
656
  prsThisCycle,
481
- goalProgress
657
+ goalProgress,
658
+ progressionDecisions,
659
+ setCompletionRate,
660
+ swapPatterns,
661
+ cycleIntent,
662
+ adaptationNote,
663
+ previousCycles,
664
+ exerciseTrends
482
665
  };
483
666
  }
484
667
 
@@ -563,7 +746,21 @@ export function workoutSummaryContext(snapshot, sessionId) {
563
746
  }
564
747
  }
565
748
 
566
- return {
749
+ // Plan comparison — prefer the logged point-in-time prescription snapshot.
750
+ let planComparison;
751
+ if (session.prescriptionSnapshot?.exercises?.length > 0) {
752
+ planComparison = buildPlanComparison(session, exercises, session.prescriptionSnapshot.exercises);
753
+ } else if (session.programId) {
754
+ const program = (snapshot.programs ?? []).find(p => p.id === session.programId);
755
+ const matchingDay = Number.isInteger(session.programDayIndex)
756
+ ? program?.days?.[session.programDayIndex]
757
+ : program?.days?.find(d => d.title === dayName);
758
+ if (matchingDay) {
759
+ planComparison = buildPlanComparison(session, exercises, matchingDay.exercises ?? []);
760
+ }
761
+ }
762
+
763
+ const result = {
567
764
  sessionDate,
568
765
  dayName,
569
766
  totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
@@ -572,6 +769,8 @@ export function workoutSummaryContext(snapshot, sessionId) {
572
769
  recentComparisons,
573
770
  prs
574
771
  };
772
+ if (planComparison) result.planComparison = planComparison;
773
+ return result;
575
774
  }
576
775
 
577
776
  function requiredOption(options, primaryKey, legacyKey = null) {
@@ -676,5 +875,24 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
676
875
  return { ok: true, payload };
677
876
  }
678
877
 
878
+ if (normalizedCommand === 'cycle-summary-list') {
879
+ const programId = requiredOption(options, 'program-id');
880
+ return { ok: true, payload: cycleSummaryList(snapshot, programId ?? null) };
881
+ }
882
+
883
+ if (normalizedCommand === 'cycle-summary-show') {
884
+ const summaryId = requiredOption(options, 'id');
885
+ if (!summaryId) {
886
+ return { ok: false, error: '--id is required' };
887
+ }
888
+
889
+ const payload = cycleSummaryShow(snapshot, summaryId);
890
+ if (!payload) {
891
+ return { ok: false, error: `Cycle summary not found: ${summaryId}` };
892
+ }
893
+
894
+ return { ok: true, payload };
895
+ }
896
+
679
897
  return { ok: false, error: `Unknown read command: ${normalizedCommand}` };
680
898
  }