incremnt 0.8.8 → 0.8.10

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
@@ -110,7 +110,7 @@ incremnt login --session-file ~/Downloads/session.json
110
110
  | `coach observations list` | Current persisted coach observations |
111
111
  | `coach observations seen --id <id>` | Mark a coach observation as seen |
112
112
  | `coach observations dismiss --id <id>` | Dismiss an observation and suppress matching follow-ups |
113
- | `programs create --file <file>` | Create an inactive saved program from generated-plan JSON |
113
+ | `programs create --file <file>` | Create an inactive saved program from generated-plan JSON, including day-local supersets |
114
114
  | `programs propose --file <file>` | Submit a program proposal |
115
115
  | `programs proposals` | List proposals |
116
116
  | `programs proposal dismiss --id <id>` | Dismiss a proposal |
package/SKILL.md CHANGED
@@ -26,7 +26,7 @@ Append `--dry-run` to any mutating command to preview the HTTP request as `{ dry
26
26
 
27
27
  | Command | Reversibility | Notes |
28
28
  |---|---|---|
29
- | `programs create --file <f>` | Reversible in iOS (created inactive) | Creates a saved inactive program from Coach-compatible generated-plan JSON. It does not activate or replace the current plan. Use `records` or `exercises history` first for canonical names. |
29
+ | `programs create --file <f>` | Reversible in iOS (created inactive) | Creates a saved inactive program from Coach-compatible generated-plan JSON, including day-local supersets via `supersetKey`/`supersetOrder`. It does not activate or replace the current plan. Use `records` or `exercises history` first for canonical names. |
30
30
  | `programs propose --file <f>` | Reversible (proposal stays pending) | Weights omitted; iOS computes from history. Names must come from `records` or `exercises history`. |
31
31
  | `programs proposals` | Read-only lookup | Use before dismissing a proposal. |
32
32
  | `programs proposal dismiss --id <id>` | Irreversible | IDs come from `programs proposals`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
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
@@ -1,4 +1,4 @@
1
- export const contractVersion = 24;
1
+ export const contractVersion = 26;
2
2
 
3
3
  export const capabilities = {
4
4
  readOnly: false,
@@ -56,6 +56,17 @@ export const commandSchema = [
56
56
  { name: 'session-id', type: 'string', format: 'id', required: true, description: 'Session ID' }
57
57
  ]
58
58
  },
59
+ {
60
+ command: 'external changes',
61
+ id: 'external-changes',
62
+ description: 'List pending/applied app changes created outside iOS',
63
+ supportsFields: true,
64
+ agentNotes: 'Use this after creating programs or importing sessions from CLI/MCP. Pending changes are queued for iOS to apply; applied/skipped/failed statuses come from the app acknowledgement.',
65
+ options: [
66
+ { name: 'status', type: 'string', format: 'enum', enum: ['pending', 'applied', 'skipped', 'failed', 'dismissed'], required: false, description: 'Filter by status' },
67
+ { name: 'limit', type: 'number', required: false, description: 'Max changes to return (default 50, max 100)' }
68
+ ]
69
+ },
59
70
  {
60
71
  command: 'programs list',
61
72
  id: 'program-list',
@@ -278,14 +289,35 @@ export const writeCommandSchema = [
278
289
  {
279
290
  command: 'programs create',
280
291
  id: 'programs-create',
281
- description: 'Create a saved inactive program from a generated plan file',
292
+ description: 'Queue an inactive iOS program for app-side import from a generated plan file',
282
293
  usage: 'programs create --file <file>',
283
294
  dryRun: true,
284
- agentNotes: 'Mutation. Creates a brand-new inactive saved program from Coach-compatible generated-plan JSON; it does not activate or replace the current plan. External assistants should draft the JSON file, then run programs create --file <path>. Use records or exercises history before drafting exercise names; do not invent unusual exercise names.',
295
+ agentNotes: 'Mutation. Queues a brand-new inactive iOS program for app-side import from Coach-compatible generated-plan JSON; it does not activate or replace the current plan. External assistants should draft the JSON file, then run programs create --file <path>. Use external changes to watch pending/applied/failed status. Use records or exercises history before drafting exercise names; do not invent unusual exercise names. Supersets are supported with day-local supersetKey plus supersetOrder; the service converts them to native iOS supersetGroupId fields.',
285
296
  options: [
286
297
  { name: 'file', type: 'string', format: 'path', required: true, description: 'Path to Coach-compatible generated-plan JSON file' }
287
298
  ]
288
299
  },
300
+ {
301
+ command: 'sessions import',
302
+ id: 'sessions-import',
303
+ description: 'Queue generated workout history for iOS dedupe-and-append import',
304
+ usage: 'sessions import --file <file>',
305
+ dryRun: true,
306
+ agentNotes: 'Mutation. File must be JSON shaped as { sessions: [...] } using the iOS export WorkoutSession shape. The app dedupes by id and session fingerprint before appending, then acknowledges the external change.',
307
+ options: [
308
+ { name: 'file', type: 'string', format: 'path', required: true, description: 'Path to JSON file with { sessions: [...] }' }
309
+ ]
310
+ },
311
+ {
312
+ command: 'external changes backfill',
313
+ id: 'external-changes-backfill',
314
+ description: 'Backfill pending external changes from older cli-agent programs stranded in hosted snapshot state',
315
+ usage: 'external changes backfill',
316
+ dryRun: true,
317
+ mcp: false,
318
+ agentNotes: 'Mutation. Operational bridge for the previous CLI-created-program implementation. Recreates pending program.create changes for snapshot programs with externalInputProvenance.source=cli-agent.',
319
+ options: []
320
+ },
289
321
  {
290
322
  command: 'programs propose',
291
323
  id: 'programs-propose',
@@ -446,31 +478,48 @@ for (const command of writeCommandSchema) {
446
478
  }
447
479
 
448
480
  export const programDraftSchema = {
449
- description: 'JSON structure for programs create --file and programs propose --file. Weights are omitted iOS calculates from history.',
481
+ description: 'JSON structure for programs create --file and programs propose --file. This mirrors the current iOS program shape: concrete set reps, optional warmups/notes/RIR, and day-local supersetKey groups. Rep ranges and rest overrides are not supported.',
450
482
  required: ['name', 'daysPerWeek', 'equipmentTier', 'days'],
451
483
  properties: {
452
484
  name: { type: 'string', description: 'Program name' },
453
485
  equipmentTier: { type: 'string', enum: ['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly'] },
454
486
  daysPerWeek: { type: 'integer', minimum: 1, maximum: 7, description: 'Training days per week' },
487
+ volumeLevel: { type: 'string', enum: ['minimum', 'moderate', 'high'], description: 'Program volume label. Optional, defaults to moderate.' },
488
+ currentDayIndex: { type: 'integer', minimum: 0, description: 'Initial current day index. Optional, defaults to 0.' },
455
489
  trainingWeekdays: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: 'Days of week (0=Sun). Optional.' },
456
- rationale: { type: 'string', description: 'Why this program was suggested. Optional.' },
457
490
  days: {
458
491
  type: 'array',
459
492
  minItems: 1,
460
493
  items: {
461
- required: ['title', 'exercises'],
494
+ required: ['dayLabel', 'title', 'exercises'],
462
495
  properties: {
496
+ dayLabel: { type: 'string', description: 'Short day label, e.g. "Day 1".' },
463
497
  title: { type: 'string', description: 'Day name, e.g. "Upper A"' },
498
+ subtitle: { type: 'string', description: 'Optional day subtitle.' },
464
499
  exercises: {
465
500
  type: 'array',
466
501
  minItems: 1,
467
502
  items: {
468
- required: ['name', 'sets', 'reps'],
503
+ required: ['name', 'muscleGroup', 'sets'],
469
504
  properties: {
470
505
  name: { type: 'string', description: 'Exercise name (use canonical names from records or exercises history)' },
471
- muscleGroup: { type: 'string', description: 'e.g. "Chest", "Back". Optional.' },
472
- sets: { type: 'integer', minimum: 1 },
473
- reps: { type: 'integer', minimum: 1 }
506
+ muscleGroup: { type: 'string', description: 'e.g. "Chest", "Back".' },
507
+ sets: {
508
+ type: 'array',
509
+ minItems: 1,
510
+ items: {
511
+ required: ['weight', 'reps'],
512
+ properties: {
513
+ weight: { type: 'number', minimum: 0, description: 'Planned load. Use 0 for bodyweight or app-computed/manual adjustment later.' },
514
+ reps: { type: 'integer', minimum: 1, maximum: 30 },
515
+ isWarmup: { type: 'boolean', description: 'Optional warmup marker.' }
516
+ }
517
+ }
518
+ },
519
+ note: { type: 'string', description: 'Optional prescription note.' },
520
+ rir: { type: 'integer', minimum: 0, maximum: 5, description: 'Optional app-supported RIR field.' },
521
+ supersetKey: { type: 'string', description: 'Optional day-local label shared by 2+ exercises to create a native iOS superset.' },
522
+ supersetOrder: { type: 'integer', minimum: 0, maximum: 24, description: 'Required when supersetKey is present; order within that superset group.' }
474
523
  }
475
524
  }
476
525
  }
@@ -484,7 +533,26 @@ export const proposalSchema = programDraftSchema;
484
533
 
485
534
  export const writePayloadSchemas = {
486
535
  'programs-create': programDraftSchema,
487
- 'programs-propose': programDraftSchema
536
+ 'programs-propose': programDraftSchema,
537
+ 'sessions-import': {
538
+ description: 'JSON structure for sessions import --file. Uses the iOS WorkoutSession export shape.',
539
+ required: ['sessions'],
540
+ properties: {
541
+ sessions: {
542
+ type: 'array',
543
+ items: {
544
+ required: ['id', 'date', 'volume', 'exercises'],
545
+ properties: {
546
+ id: { type: 'string', description: 'Stable session UUID' },
547
+ date: { type: 'string', description: 'Session date label or yyyy-MM-dd date' },
548
+ completedAt: { type: 'string', description: 'Optional ISO completion timestamp' },
549
+ volume: { type: 'integer', minimum: 0 },
550
+ exercises: { type: 'array', description: 'Logged exercises in iOS export shape' }
551
+ }
552
+ }
553
+ }
554
+ }
555
+ }
488
556
  };
489
557
 
490
558
  // Describes how token scope gates commands, so an agent can decide before
@@ -0,0 +1,274 @@
1
+ export async function handleExternalAppChangeRoute({
2
+ route,
3
+ request,
4
+ response,
5
+ requestToken,
6
+ token,
7
+ readAuthenticator,
8
+ connectedWriteAuthenticator,
9
+ mobileSyncAuthenticator,
10
+ createExternalChangeForAccount,
11
+ listExternalChangesForAccount,
12
+ updateExternalChangeResultForAccount,
13
+ backfillExternalChangesForAccount,
14
+ readJsonBody,
15
+ rejectInsufficientScopeForReadToken,
16
+ json,
17
+ methodNotAllowed,
18
+ unauthorized,
19
+ badRequest,
20
+ notFound
21
+ }) {
22
+ if (route.command === 'external-changes') {
23
+ if (request.method === 'GET' && request.url?.startsWith('/mobile/')) {
24
+ if (!listExternalChangesForAccount) {
25
+ methodNotAllowed(response, 'External change pull is not enabled for this service mode.');
26
+ return true;
27
+ }
28
+ const account = mobileSyncAuthenticator
29
+ ? await mobileSyncAuthenticator(requestToken)
30
+ : null;
31
+ if (!account) {
32
+ unauthorized(response, request);
33
+ return true;
34
+ }
35
+ const status = route.options.status ?? 'pending';
36
+ try {
37
+ json(response, 200, await listExternalChangesForAccount(account, { status, limit: 100 }));
38
+ } catch (error) {
39
+ badRequest(response, error.message);
40
+ }
41
+ return true;
42
+ }
43
+
44
+ if (request.method === 'GET') {
45
+ if (!listExternalChangesForAccount) {
46
+ methodNotAllowed(response, 'External change listing is not enabled for this service mode.');
47
+ return true;
48
+ }
49
+ const account = readAuthenticator
50
+ ? await readAuthenticator(requestToken)
51
+ : null;
52
+ if (!account) {
53
+ unauthorized(response, request);
54
+ return true;
55
+ }
56
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
57
+ const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
58
+ try {
59
+ json(response, 200, await listExternalChangesForAccount(account, {
60
+ status: route.options.status,
61
+ limit
62
+ }));
63
+ } catch (error) {
64
+ badRequest(response, error.message);
65
+ }
66
+ return true;
67
+ }
68
+
69
+ if (request.method === 'POST') {
70
+ if (!createExternalChangeForAccount) {
71
+ methodNotAllowed(response, 'External change creation is not enabled for this service mode.');
72
+ return true;
73
+ }
74
+ const account = connectedWriteAuthenticator
75
+ ? await connectedWriteAuthenticator(requestToken)
76
+ : requestToken === token
77
+ ? { id: 'remote-user', email: null }
78
+ : null;
79
+ if (!account) {
80
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
81
+ return true;
82
+ }
83
+ unauthorized(response, request);
84
+ return true;
85
+ }
86
+ try {
87
+ const body = await readJsonBody(request);
88
+ const result = await createExternalChangeForAccount(account, body ?? {});
89
+ json(response, 201, { ok: true, externalChange: result });
90
+ return true;
91
+ } catch (error) {
92
+ badRequest(response, error.message);
93
+ return true;
94
+ }
95
+ }
96
+
97
+ methodNotAllowed(response, 'Use GET or POST for /cli/external-changes, or GET for /mobile/external-changes.');
98
+ return true;
99
+ }
100
+
101
+ if (route.command === 'external-change-result') {
102
+ if (request.method !== 'POST') {
103
+ methodNotAllowed(response, 'Use POST for /mobile/external-changes/:id/result.');
104
+ return true;
105
+ }
106
+ if (!updateExternalChangeResultForAccount) {
107
+ methodNotAllowed(response, 'External change result updates are not enabled for this service mode.');
108
+ return true;
109
+ }
110
+ const account = mobileSyncAuthenticator
111
+ ? await mobileSyncAuthenticator(requestToken)
112
+ : null;
113
+ if (!account) {
114
+ unauthorized(response, request);
115
+ return true;
116
+ }
117
+ try {
118
+ const body = await readJsonBody(request);
119
+ const result = await updateExternalChangeResultForAccount(account, route.options.id, body ?? {});
120
+ if (!result) {
121
+ notFound(response, 'External change not found.');
122
+ return true;
123
+ }
124
+ json(response, 200, { ok: true, externalChange: result });
125
+ return true;
126
+ } catch (error) {
127
+ badRequest(response, error.message);
128
+ return true;
129
+ }
130
+ }
131
+
132
+ if (route.command === 'external-changes-backfill') {
133
+ if (request.method !== 'POST') {
134
+ methodNotAllowed(response, 'Use POST for /cli/external-changes/backfill.');
135
+ return true;
136
+ }
137
+ if (!backfillExternalChangesForAccount) {
138
+ methodNotAllowed(response, 'External change backfill is not enabled for this service mode.');
139
+ return true;
140
+ }
141
+ const account = connectedWriteAuthenticator
142
+ ? await connectedWriteAuthenticator(requestToken)
143
+ : requestToken === token
144
+ ? { id: 'remote-user', email: null }
145
+ : null;
146
+ if (!account) {
147
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
148
+ return true;
149
+ }
150
+ unauthorized(response, request);
151
+ return true;
152
+ }
153
+ try {
154
+ const result = await backfillExternalChangesForAccount(account);
155
+ json(response, 200, result);
156
+ return true;
157
+ } catch (error) {
158
+ badRequest(response, error.message);
159
+ return true;
160
+ }
161
+ }
162
+
163
+ return false;
164
+ }
165
+
166
+ export async function handleAppNotificationRoute({
167
+ route,
168
+ request,
169
+ response,
170
+ requestToken,
171
+ token,
172
+ readAuthenticator,
173
+ notifications,
174
+ json,
175
+ logRequest,
176
+ methodNotAllowed,
177
+ unauthorized,
178
+ notFound,
179
+ badRequest,
180
+ internalError,
181
+ onError
182
+ }) {
183
+ if (!notifications) return false;
184
+
185
+ if (route.command === 'notifications') {
186
+ if (request.method !== 'GET') {
187
+ methodNotAllowed(response, 'Use GET for /cli/notifications.');
188
+ return true;
189
+ }
190
+ const notificationAccount = readAuthenticator
191
+ ? await readAuthenticator(requestToken)
192
+ : requestToken === token
193
+ ? { id: 'remote-user', email: null }
194
+ : null;
195
+ if (!notificationAccount) {
196
+ unauthorized(response, request);
197
+ return true;
198
+ }
199
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
200
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
201
+ try {
202
+ const payload = await notifications.list(notificationAccount.id, {
203
+ limit,
204
+ before: route.options.before ?? null
205
+ });
206
+ logRequest(request, 200);
207
+ json(response, 200, payload);
208
+ return true;
209
+ } catch (error) {
210
+ internalError(response, error, onError);
211
+ return true;
212
+ }
213
+ }
214
+
215
+ if (route.command === 'notification-read') {
216
+ if (request.method !== 'POST') {
217
+ methodNotAllowed(response, 'Use POST for /cli/notifications/:id/read.');
218
+ return true;
219
+ }
220
+ const notificationAccount = readAuthenticator
221
+ ? await readAuthenticator(requestToken)
222
+ : requestToken === token
223
+ ? { id: 'remote-user', email: null }
224
+ : null;
225
+ if (!notificationAccount) {
226
+ unauthorized(response, request);
227
+ return true;
228
+ }
229
+ try {
230
+ const notification = await notifications.markRead(notificationAccount.id, route.options.notificationId);
231
+ if (!notification) {
232
+ notFound(response, 'Notification not found.');
233
+ return true;
234
+ }
235
+ logRequest(request, 200);
236
+ json(response, 200, notification);
237
+ return true;
238
+ } catch (error) {
239
+ if (error?.message === 'notificationId is required.') {
240
+ badRequest(response, error.message);
241
+ return true;
242
+ }
243
+ internalError(response, error, onError);
244
+ return true;
245
+ }
246
+ }
247
+
248
+ if (route.command === 'notification-read-all') {
249
+ if (request.method !== 'POST') {
250
+ methodNotAllowed(response, 'Use POST for /cli/notifications/read-all.');
251
+ return true;
252
+ }
253
+ const notificationAccount = readAuthenticator
254
+ ? await readAuthenticator(requestToken)
255
+ : requestToken === token
256
+ ? { id: 'remote-user', email: null }
257
+ : null;
258
+ if (!notificationAccount) {
259
+ unauthorized(response, request);
260
+ return true;
261
+ }
262
+ try {
263
+ const result = await notifications.markAllRead(notificationAccount.id);
264
+ logRequest(request, 200);
265
+ json(response, 200, result);
266
+ return true;
267
+ } catch (error) {
268
+ internalError(response, error, onError);
269
+ return true;
270
+ }
271
+ }
272
+
273
+ return false;
274
+ }
package/src/format.js CHANGED
@@ -599,15 +599,16 @@ function formatProposalCreated(payload) {
599
599
  function formatProgramCreated(payload) {
600
600
  if (!payload) return 'No program data.';
601
601
  const lines = [
602
- header('PROGRAM CREATED'),
602
+ header('PROGRAM QUEUED'),
603
603
  '',
604
604
  keyValue('ID', payload.id),
605
- keyValue('Status', payload.status ?? 'inactive'),
605
+ keyValue('External change', payload.externalChangeId ?? payload.externalChange?.id ?? '?'),
606
+ keyValue('Status', payload.status ?? 'pending'),
606
607
  keyValue('Program', payload.name ?? 'Untitled'),
607
608
  keyValue('Days/week', String(payload.daysPerWeek ?? '?')),
608
609
  keyValue('Days', String(payload.days ?? payload.dayCount ?? '?')),
609
610
  '',
610
- chalk.dim(' Created as an inactive saved program. Activate it later from iOS Programs.')
611
+ chalk.dim(' Queued for iOS import. Run `incremnt external changes` to check applied/skipped/failed status.')
611
612
  ];
612
613
  return lines.join('\n');
613
614
  }
@@ -18,12 +18,15 @@ export const PROGRAM_DRAFT_LIMITS = {
18
18
  dayTitleMaxLen: 120,
19
19
  daySubtitleMaxLen: 120,
20
20
  noteMaxLen: 1000,
21
+ supersetKeyMaxLen: 64,
21
22
  minWeight: 0,
22
23
  maxWeight: 600,
23
24
  minReps: 1,
24
25
  maxReps: 30,
25
26
  minRir: 0,
26
27
  maxRir: 5,
28
+ minSupersetOrder: 0,
29
+ maxSupersetOrder: 24,
27
30
  minSetsPerExercise: 1,
28
31
  maxSetsPerExercise: 12,
29
32
  minExercisesPerDay: 1,
@@ -81,7 +84,7 @@ function normalizeProgramDraftSet(set) {
81
84
  }
82
85
 
83
86
  function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName, strict = false) {
84
- if (!hasOnlyAllowedKeys(exercise, new Set(['name', 'muscleGroup', 'sets', 'rir', 'note']))) return null;
87
+ if (!hasOnlyAllowedKeys(exercise, new Set(['name', 'muscleGroup', 'sets', 'rir', 'note', 'supersetKey', 'supersetOrder']))) return null;
85
88
 
86
89
  const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
87
90
  const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
@@ -109,6 +112,20 @@ function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName, stric
109
112
  const note = exercise?.note == null ? null : String(exercise.note);
110
113
  if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
111
114
 
115
+ const supersetKey = exercise?.supersetKey == null ? null : String(exercise.supersetKey).trim();
116
+ if (supersetKey != null && (
117
+ !supersetKey ||
118
+ supersetKey.length > PROGRAM_DRAFT_LIMITS.supersetKeyMaxLen
119
+ )) return null;
120
+
121
+ const supersetOrder = exercise?.supersetOrder == null ? null : Number(exercise.supersetOrder);
122
+ if (supersetKey == null && supersetOrder != null) return null;
123
+ if (supersetKey != null && (
124
+ !Number.isInteger(supersetOrder) ||
125
+ supersetOrder < PROGRAM_DRAFT_LIMITS.minSupersetOrder ||
126
+ supersetOrder > PROGRAM_DRAFT_LIMITS.maxSupersetOrder
127
+ )) return null;
128
+
112
129
  return {
113
130
  name,
114
131
  muscleGroup,
@@ -116,10 +133,33 @@ function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName, stric
116
133
  nextSuggestion: '',
117
134
  sets,
118
135
  ...(note ? { note } : {}),
119
- ...(rir != null ? { rir } : {})
136
+ ...(rir != null ? { rir } : {}),
137
+ ...(supersetKey != null ? { supersetKey, supersetOrder } : {})
120
138
  };
121
139
  }
122
140
 
141
+ function hasValidDaySupersets(exercises) {
142
+ const grouped = new Map();
143
+ for (const exercise of exercises) {
144
+ if (!exercise.supersetKey) continue;
145
+ const existing = grouped.get(exercise.supersetKey) ?? [];
146
+ existing.push(exercise);
147
+ grouped.set(exercise.supersetKey, existing);
148
+ }
149
+
150
+ for (const group of grouped.values()) {
151
+ if (group.length < 2) return false;
152
+ const orders = new Set();
153
+ for (const exercise of group) {
154
+ if (!Number.isInteger(exercise.supersetOrder)) return false;
155
+ if (orders.has(exercise.supersetOrder)) return false;
156
+ orders.add(exercise.supersetOrder);
157
+ }
158
+ }
159
+
160
+ return true;
161
+ }
162
+
123
163
  function normalizeProgramDraftDay(day, canonicalizeExerciseName, strict = false) {
124
164
  if (!hasOnlyAllowedKeys(day, new Set(['dayLabel', 'title', 'subtitle', 'exercises']))) return null;
125
165
 
@@ -139,6 +179,7 @@ function normalizeProgramDraftDay(day, canonicalizeExerciseName, strict = false)
139
179
  exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
140
180
  exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
141
181
  ) return null;
182
+ if (!hasValidDaySupersets(exercises)) return null;
142
183
 
143
184
  return { dayLabel, title, subtitle, exercises };
144
185
  }
@@ -219,10 +260,15 @@ function defaultChildIdFactory() {
219
260
  return randomUUID();
220
261
  }
221
262
 
263
+ function defaultSupersetIdFactory() {
264
+ return randomUUID();
265
+ }
266
+
222
267
  export function buildProgramFromDraft(rawProgram, {
223
268
  now = new Date(),
224
269
  idFactory = defaultProgramIdFactory,
225
- childIdFactory = defaultChildIdFactory
270
+ childIdFactory = defaultChildIdFactory,
271
+ supersetIdFactory = defaultSupersetIdFactory
226
272
  } = {}) {
227
273
  const draft = normalizeProgramDraft(rawProgram, { strict: true });
228
274
  if (!draft) {
@@ -230,28 +276,44 @@ export function buildProgramFromDraft(rawProgram, {
230
276
  }
231
277
 
232
278
  const createdAt = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
233
- const days = draft.days.map((day) => ({
234
- id: childIdFactory('day'),
235
- dayLabel: day.dayLabel,
236
- title: day.title,
237
- subtitle: day.subtitle,
238
- exercises: day.exercises.map((exercise) => ({
239
- id: childIdFactory('exercise'),
240
- name: exercise.name,
241
- muscleGroup: exercise.muscleGroup,
242
- lastSuggestion: exercise.lastSuggestion,
243
- nextSuggestion: exercise.nextSuggestion,
244
- sets: exercise.sets.map((set) => ({
245
- id: childIdFactory('set'),
246
- weight: set.weight,
247
- reps: set.reps,
248
- isComplete: false,
249
- isWarmup: set.isWarmup === true
250
- })),
251
- ...(exercise.note ? { note: exercise.note } : {}),
252
- ...(exercise.rir != null ? { rir: exercise.rir } : {})
253
- }))
254
- }));
279
+ const days = draft.days.map((day) => {
280
+ const supersetGroupIds = new Map();
281
+ const groupIdForKey = (key) => {
282
+ if (!supersetGroupIds.has(key)) {
283
+ supersetGroupIds.set(key, supersetIdFactory('superset'));
284
+ }
285
+ return supersetGroupIds.get(key);
286
+ };
287
+
288
+ return {
289
+ id: childIdFactory('day'),
290
+ dayLabel: day.dayLabel,
291
+ title: day.title,
292
+ subtitle: day.subtitle,
293
+ exercises: day.exercises.map((exercise) => ({
294
+ id: childIdFactory('exercise'),
295
+ name: exercise.name,
296
+ muscleGroup: exercise.muscleGroup,
297
+ lastSuggestion: exercise.lastSuggestion,
298
+ nextSuggestion: exercise.nextSuggestion,
299
+ sets: exercise.sets.map((set) => ({
300
+ id: childIdFactory('set'),
301
+ weight: set.weight,
302
+ reps: set.reps,
303
+ isComplete: false,
304
+ isWarmup: set.isWarmup === true
305
+ })),
306
+ ...(exercise.note ? { note: exercise.note } : {}),
307
+ ...(exercise.rir != null ? { rir: exercise.rir } : {}),
308
+ ...(exercise.supersetKey
309
+ ? {
310
+ supersetGroupId: groupIdForKey(exercise.supersetKey),
311
+ supersetOrder: exercise.supersetOrder
312
+ }
313
+ : {})
314
+ }))
315
+ };
316
+ });
255
317
 
256
318
  return {
257
319
  id: idFactory('program'),
package/src/remote.js CHANGED
@@ -133,6 +133,7 @@ async function throwForbiddenResponse(response, sessionState, commandId) {
133
133
  const remoteCommandHandlers = {
134
134
  'session-insights': executeRemoteRead,
135
135
  'session-show': executeRemoteRead,
136
+ 'external-changes': executeRemoteRead,
136
137
  'exercise-history': executeRemoteRead,
137
138
  records: executeRemoteRead,
138
139
  'program-list': executeRemoteRead,
@@ -245,6 +246,12 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
245
246
  return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/compare`);
246
247
  case 'why-did-this-change':
247
248
  return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/explain`);
249
+ case 'external-changes': {
250
+ const url = resolveServiceUrl(baseUrl, '/cli/external-changes');
251
+ if (options.status) url.searchParams.set('status', options.status);
252
+ if (options.limit) url.searchParams.set('limit', options.limit);
253
+ return url;
254
+ }
248
255
  case 'program-list':
249
256
  return resolveServiceUrl(baseUrl, '/cli/programs');
250
257
  case 'program-summary':
@@ -501,6 +508,35 @@ export async function buildWriteRequest(commandId, options, sessionState) {
501
508
  body: draft
502
509
  };
503
510
  }
511
+ case 'sessions-import': {
512
+ if (!options.file) {
513
+ const error = new Error('--file is required for sessions import.');
514
+ error.code = 'MISSING_OPTION';
515
+ throw error;
516
+ }
517
+ const raw = await fs.readFile(options.file, 'utf8');
518
+ const payload = JSON.parse(raw);
519
+ return {
520
+ method: 'POST',
521
+ url: resolveServiceUrl(baseUrl, '/cli/external-changes').toString(),
522
+ body: {
523
+ kind: 'sessions.import',
524
+ source: {
525
+ surface: 'cli',
526
+ command: 'sessions import',
527
+ version: 1
528
+ },
529
+ payload
530
+ }
531
+ };
532
+ }
533
+ case 'external-changes-backfill': {
534
+ return {
535
+ method: 'POST',
536
+ url: resolveServiceUrl(baseUrl, '/cli/external-changes/backfill').toString(),
537
+ body: null
538
+ };
539
+ }
504
540
  case 'programs-propose': {
505
541
  if (!options.file) {
506
542
  const error = new Error('--file is required for programs propose.');
@@ -719,6 +755,51 @@ const remoteWriteCommandHandlers = {
719
755
  return response.json();
720
756
  },
721
757
 
758
+ 'sessions-import': async (options, sessionState) => {
759
+ const baseUrl = sessionState.session?.transport?.baseUrl;
760
+ if (!baseUrl) throw notImplementedError();
761
+
762
+ const filePath = options.file;
763
+ if (!filePath) {
764
+ const error = new Error('--file is required for sessions import.');
765
+ error.code = 'MISSING_OPTION';
766
+ throw error;
767
+ }
768
+
769
+ const raw = await fs.readFile(filePath, 'utf8');
770
+ const payload = JSON.parse(raw);
771
+ const body = {
772
+ kind: 'sessions.import',
773
+ source: {
774
+ surface: 'cli',
775
+ command: 'sessions import',
776
+ version: 1
777
+ },
778
+ payload
779
+ };
780
+
781
+ const endpoint = resolveServiceUrl(baseUrl, '/cli/external-changes');
782
+ const response = await fetch(endpoint, {
783
+ method: 'POST',
784
+ headers: {
785
+ 'Content-Type': 'application/json',
786
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
787
+ },
788
+ body: JSON.stringify(body)
789
+ });
790
+
791
+ if (response.status === 401) throw authenticationFailedError();
792
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'sessions-import');
793
+ if (!response.ok) {
794
+ const responsePayload = await response.json().catch(() => null);
795
+ const error = new Error(responsePayload?.error ?? `Unexpected error (HTTP ${response.status}).`);
796
+ error.code = 'REMOTE_HTTP_ERROR';
797
+ throw error;
798
+ }
799
+
800
+ return response.json();
801
+ },
802
+
722
803
  'programs-proposals': async (options, sessionState) => {
723
804
  const baseUrl = sessionState.session?.transport?.baseUrl;
724
805
  if (!baseUrl) throw notImplementedError();
@@ -783,6 +864,30 @@ const remoteWriteCommandHandlers = {
783
864
  return response.json();
784
865
  },
785
866
 
867
+ 'external-changes-backfill': async (_options, sessionState) => {
868
+ const baseUrl = sessionState.session?.transport?.baseUrl;
869
+ if (!baseUrl) throw notImplementedError();
870
+
871
+ const endpoint = resolveServiceUrl(baseUrl, '/cli/external-changes/backfill');
872
+ const response = await fetch(endpoint, {
873
+ method: 'POST',
874
+ headers: {
875
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
876
+ }
877
+ });
878
+
879
+ if (response.status === 401) throw authenticationFailedError();
880
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'external-changes-backfill');
881
+ if (!response.ok) {
882
+ const payload = await response.json().catch(() => null);
883
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
884
+ error.code = 'REMOTE_HTTP_ERROR';
885
+ throw error;
886
+ }
887
+
888
+ return response.json();
889
+ },
890
+
786
891
  'program-share-create': async (options, sessionState) => {
787
892
  const baseUrl = sessionState.session?.transport?.baseUrl;
788
893
  if (!baseUrl) throw notImplementedError();
@@ -24,7 +24,11 @@ import { capabilities as cliCapabilities, contractVersion, officialCommands } fr
24
24
  import { canonicalExerciseName, executeReadCommand } from './queries.js';
25
25
  import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
26
26
  import { compactScoreHistorySnapshots, enrichScoreSnapshots } from './score-context.js';
27
- import { buildProgramFromDraft, extractAskProgramDraft } from './program-draft.js';
27
+ import { buildProgramFromDraft, extractAskProgramDraft, normalizeProgramDraft } from './program-draft.js';
28
+ import {
29
+ handleAppNotificationRoute,
30
+ handleExternalAppChangeRoute
31
+ } from './external-app-change-routes.js';
28
32
  import { extractPlanChangeset } from './plan-changeset.js';
29
33
  import { extractProgramScheduleAction } from './program-schedule-action.js';
30
34
  import { extractCoachAdvice } from './coach-advice.js';
@@ -89,6 +93,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
89
93
  'sync-account-preferences': 30,
90
94
  'proposals': 30,
91
95
  'proposal-update': 30,
96
+ 'external-changes': 60,
97
+ 'external-change-result': 60,
98
+ 'external-changes-backfill': 10,
92
99
  'program-share-create': 30,
93
100
  'program-share-list': 60,
94
101
  'program-share-public': 120,
@@ -133,6 +140,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
133
140
  'social-media-complete': 30,
134
141
  'social-media-delete': 30,
135
142
  'social-post-update': 30,
143
+ 'notifications': 60,
144
+ 'notification-read': 60,
145
+ 'notification-read-all': 30,
136
146
  'social-notifications': 60,
137
147
  'social-notification-read': 60,
138
148
  'social-notification-read-all': 30,
@@ -311,6 +321,27 @@ function stableAdviceObservationKey(advice) {
311
321
  return `ask-advice:${createHash('sha256').update(material).digest('hex').slice(0, 24)}`;
312
322
  }
313
323
 
324
+ function stableJson(value) {
325
+ if (Array.isArray(value)) {
326
+ return `[${value.map(stableJson).join(',')}]`;
327
+ }
328
+ if (value && typeof value === 'object') {
329
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
330
+ }
331
+ return JSON.stringify(value);
332
+ }
333
+
334
+ function programCreateIdempotencyKey(rawDraft) {
335
+ const draft = normalizeProgramDraft(rawDraft, { strict: true });
336
+ if (!draft) {
337
+ throw new Error('Invalid generated program draft.');
338
+ }
339
+ const digest = createHash('sha256')
340
+ .update(stableJson({ command: 'programs create', version: 1, draft }), 'utf8')
341
+ .digest('hex');
342
+ return `program.create:cli-draft:sha256:${digest}`;
343
+ }
344
+
314
345
  function confidenceScore(confidence) {
315
346
  if (confidence === 'high') return 0.85;
316
347
  if (confidence === 'medium') return 0.7;
@@ -1492,6 +1523,25 @@ function routeRequest(url, method) {
1492
1523
  return { command: 'mobile-sync-push', options: {} };
1493
1524
  }
1494
1525
 
1526
+ if (pathname === '/mobile/external-changes') {
1527
+ return {
1528
+ command: 'external-changes',
1529
+ options: {
1530
+ status: url.searchParams.get('status') ?? 'pending'
1531
+ }
1532
+ };
1533
+ }
1534
+
1535
+ {
1536
+ const externalChangeResultMatch = pathname.match(/^\/mobile\/external-changes\/([^/]+)\/result$/);
1537
+ if (externalChangeResultMatch) {
1538
+ return {
1539
+ command: 'external-change-result',
1540
+ options: { id: decodeURIComponent(externalChangeResultMatch[1]) }
1541
+ };
1542
+ }
1543
+ }
1544
+
1495
1545
  if (pathname === '/mobile/score-snapshots') {
1496
1546
  return {
1497
1547
  command: 'score-snapshots',
@@ -1566,6 +1616,20 @@ function routeRequest(url, method) {
1566
1616
  return { command: 'proposals', options: { status: url.searchParams.get('status') ?? undefined } };
1567
1617
  }
1568
1618
 
1619
+ if (pathname === '/cli/external-changes') {
1620
+ return {
1621
+ command: 'external-changes',
1622
+ options: {
1623
+ status: url.searchParams.get('status') ?? undefined,
1624
+ limit: url.searchParams.get('limit') ?? undefined
1625
+ }
1626
+ };
1627
+ }
1628
+
1629
+ if (pathname === '/cli/external-changes/backfill') {
1630
+ return { command: 'external-changes-backfill', options: {} };
1631
+ }
1632
+
1569
1633
  {
1570
1634
  const programShareCreateMatch = pathname.match(/^\/cli\/programs\/([^/]+)\/share$/);
1571
1635
  if (programShareCreateMatch) {
@@ -2153,9 +2217,9 @@ function routeRequest(url, method) {
2153
2217
  }
2154
2218
  }
2155
2219
 
2156
- if (pathname === '/cli/social/notifications') {
2220
+ if (pathname === '/cli/notifications' || pathname === '/cli/social/notifications') {
2157
2221
  return {
2158
- command: 'social-notifications',
2222
+ command: pathname === '/cli/notifications' ? 'notifications' : 'social-notifications',
2159
2223
  options: {
2160
2224
  limit: url.searchParams.get('limit') ?? undefined,
2161
2225
  before: url.searchParams.get('before') ?? undefined
@@ -2163,15 +2227,15 @@ function routeRequest(url, method) {
2163
2227
  };
2164
2228
  }
2165
2229
 
2166
- if (pathname === '/cli/social/notifications/read-all') {
2167
- return { command: 'social-notification-read-all', options: {} };
2230
+ if (pathname === '/cli/notifications/read-all' || pathname === '/cli/social/notifications/read-all') {
2231
+ return { command: pathname === '/cli/notifications/read-all' ? 'notification-read-all' : 'social-notification-read-all', options: {} };
2168
2232
  }
2169
2233
 
2170
2234
  {
2171
- const notificationReadMatch = pathname.match(/^\/cli\/social\/notifications\/([^/]+)\/read$/);
2235
+ const notificationReadMatch = pathname.match(/^\/cli\/(?:social\/)?notifications\/([^/]+)\/read$/);
2172
2236
  if (notificationReadMatch) {
2173
2237
  return {
2174
- command: 'social-notification-read',
2238
+ command: pathname.startsWith('/cli/notifications/') ? 'notification-read' : 'social-notification-read',
2175
2239
  options: { notificationId: decodeURIComponent(notificationReadMatch[1]) }
2176
2240
  };
2177
2241
  }
@@ -2780,6 +2844,10 @@ export function createSyncServiceRequestHandler({
2780
2844
  createProposalForAccount = null,
2781
2845
  listProposalsForAccount = null,
2782
2846
  updateProposalForAccount = null,
2847
+ createExternalChangeForAccount = null,
2848
+ listExternalChangesForAccount = null,
2849
+ updateExternalChangeResultForAccount = null,
2850
+ backfillExternalChangesForAccount = null,
2783
2851
  createProgramShareForAccount = null,
2784
2852
  listProgramSharesForAccount = null,
2785
2853
  readPublicProgramShare = null,
@@ -2823,6 +2891,7 @@ export function createSyncServiceRequestHandler({
2823
2891
  recordCoachObservationOutcomeForAccount = null,
2824
2892
  recordCoachObservationFeedbackForAccount = null,
2825
2893
  // Social
2894
+ notifications = null,
2826
2895
  social = null,
2827
2896
  onError = null
2828
2897
  }) {
@@ -3810,6 +3879,30 @@ export function createSyncServiceRequestHandler({
3810
3879
  return;
3811
3880
  }
3812
3881
 
3882
+ if (await handleExternalAppChangeRoute({
3883
+ route,
3884
+ request,
3885
+ response,
3886
+ requestToken,
3887
+ token,
3888
+ readAuthenticator,
3889
+ connectedWriteAuthenticator,
3890
+ mobileSyncAuthenticator,
3891
+ createExternalChangeForAccount,
3892
+ listExternalChangesForAccount,
3893
+ updateExternalChangeResultForAccount,
3894
+ backfillExternalChangesForAccount,
3895
+ readJsonBody,
3896
+ rejectInsufficientScopeForReadToken,
3897
+ json,
3898
+ methodNotAllowed,
3899
+ unauthorized,
3900
+ badRequest,
3901
+ notFound
3902
+ })) {
3903
+ return;
3904
+ }
3905
+
3813
3906
  if (route.command === 'score-snapshots') {
3814
3907
  if (request.method === 'POST') {
3815
3908
  if (!insertScoreSnapshotsForAccount) {
@@ -7852,6 +7945,26 @@ export function createSyncServiceRequestHandler({
7852
7945
  }
7853
7946
  }
7854
7947
 
7948
+ if (await handleAppNotificationRoute({
7949
+ route,
7950
+ request,
7951
+ response,
7952
+ requestToken,
7953
+ token,
7954
+ readAuthenticator,
7955
+ notifications,
7956
+ json,
7957
+ logRequest,
7958
+ methodNotAllowed,
7959
+ unauthorized,
7960
+ notFound,
7961
+ badRequest,
7962
+ internalError,
7963
+ onError
7964
+ })) {
7965
+ return;
7966
+ }
7967
+
7855
7968
  if (social && route.command === 'social-notifications') {
7856
7969
  if (request.method !== 'GET') {
7857
7970
  methodNotAllowed(response, 'Use GET for /cli/social/notifications.');
@@ -8014,7 +8127,7 @@ export function createSyncServiceRequestHandler({
8014
8127
  }
8015
8128
 
8016
8129
  if (route.command === 'program-list' && request.method === 'POST') {
8017
- if (!pushMobileSyncChangesForAccount) {
8130
+ if (!createExternalChangeForAccount) {
8018
8131
  methodNotAllowed(response, 'Program creation is not enabled for this service mode.');
8019
8132
  return;
8020
8133
  }
@@ -8034,25 +8147,28 @@ export function createSyncServiceRequestHandler({
8034
8147
 
8035
8148
  try {
8036
8149
  const body = await readJsonBody(request);
8150
+ const idempotencyKey = programCreateIdempotencyKey(body);
8037
8151
  const program = buildProgramFromDraft(body);
8038
- await pushMobileSyncChangesForAccount(writeAccount, {
8039
- deviceId: 'cli-program-create',
8040
- changes: [
8041
- {
8042
- type: 'program',
8043
- id: program.id,
8044
- updatedAt: program.externalInputProvenance.createdAt,
8045
- payload: program
8046
- }
8047
- ]
8152
+ const externalChange = await createExternalChangeForAccount(writeAccount, {
8153
+ kind: 'program.create',
8154
+ source: {
8155
+ surface: 'cli',
8156
+ command: 'programs create',
8157
+ version: 1
8158
+ },
8159
+ payload: { program },
8160
+ idempotencyKey
8048
8161
  });
8162
+ const queuedProgram = externalChange?.payload?.program ?? program;
8049
8163
  json(response, 201, {
8050
8164
  ok: true,
8051
- id: program.id,
8052
- name: program.name,
8053
- status: 'inactive',
8054
- daysPerWeek: program.daysPerWeek,
8055
- days: program.days.length
8165
+ id: queuedProgram.id,
8166
+ name: queuedProgram.name,
8167
+ status: externalChange.status,
8168
+ externalChangeId: externalChange.id,
8169
+ externalChange,
8170
+ daysPerWeek: queuedProgram.daysPerWeek,
8171
+ days: queuedProgram.days.length
8056
8172
  });
8057
8173
  return;
8058
8174
  } catch (error) {