incremnt 0.8.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.8.9",
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 = 25;
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. Supersets are supported with day-local supersetKey plus supersetOrder; the service converts them to native iOS supersetGroupId fields.',
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',
@@ -501,7 +533,26 @@ export const proposalSchema = programDraftSchema;
501
533
 
502
534
  export const writePayloadSchemas = {
503
535
  'programs-create': programDraftSchema,
504
- '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
+ }
505
556
  };
506
557
 
507
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
  }
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) {