incremnt 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/remote.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { askMissingObservationFollowUpContext, askObservationFollowUpContext, askRoutedContext } from './ask-coach.js';
4
+ import { contractVersion, writeCommandSchema } from './contract.js';
2
5
  import { readSnapshot } from './local.js';
3
- import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand } from './queries.js';
6
+ import { sanitizeHistory } from './prompt-security.js';
7
+ import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand, shouldKeepCurrentCoachObservation } from './queries.js';
4
8
  import { resolveServiceUrl } from './service-url.js';
5
9
 
6
10
  function notImplementedError() {
@@ -21,6 +25,111 @@ function authenticationFailedError() {
21
25
  return error;
22
26
  }
23
27
 
28
+ function insufficientScopeError(message = 'This agent token is read-only. Create or use a write-capable agent token for this command.') {
29
+ const error = new Error(message);
30
+ error.code = 'INSUFFICIENT_SCOPE';
31
+ // Structured remedy so an agent can act instead of retry-looping. Minting a
32
+ // write token requires a human login, so this is an escalation, not something
33
+ // the agent can self-correct.
34
+ error.requiredAccess = 'write';
35
+ error.requiresHuman = true;
36
+ error.remedy = 'A write-capable agent token is required. Minting one needs a human login: run `incremnt login`, then `incremnt agents create --access write`.';
37
+ return error;
38
+ }
39
+
40
+ function remoteContractMismatchError(remoteVersion) {
41
+ const parsedRemoteVersion = Number(remoteVersion);
42
+ const remoteLabel = remoteVersion == null ? 'missing' : `v${remoteVersion}`;
43
+ const action = Number.isFinite(parsedRemoteVersion) && parsedRemoteVersion > contractVersion
44
+ ? 'Update the incremnt CLI, then run incremnt login again.'
45
+ : 'Run incremnt login again after the service updates.';
46
+ const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got ${remoteLabel}. ${action}`);
47
+ error.code = 'REMOTE_CONTRACT_MISMATCH';
48
+ return error;
49
+ }
50
+
51
+ function remoteTimeoutError(timeoutMs = REMOTE_ASK_PLAN_TIMEOUT_MS) {
52
+ const error = new Error(`Remote Ask planning timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
53
+ error.code = 'REMOTE_TIMEOUT';
54
+ return error;
55
+ }
56
+
57
+ function assertValidAskPlanQuestion(question) {
58
+ if (!question || typeof question !== 'string' || question.trim().length === 0) {
59
+ const error = new Error('question is required');
60
+ error.code = 'VALIDATION_ERROR';
61
+ throw error;
62
+ }
63
+ if (question.length > 500) {
64
+ const error = new Error('question must be 500 characters or fewer');
65
+ error.code = 'VALIDATION_ERROR';
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ const requiredAccessByWriteCommandId = new Map(
71
+ writeCommandSchema.map((command) => [command.id, command.requiredAccess ?? 'write'])
72
+ );
73
+
74
+ function requiredAccessForCommand(commandId) {
75
+ return requiredAccessByWriteCommandId.get(commandId) ?? 'read';
76
+ }
77
+
78
+ // Fail-closed gate for the write dispatch path: anything routed as a write
79
+ // command must be write-scoped UNLESS the contract explicitly marks it
80
+ // 'read'-access. An unknown id (e.g. a future write handler missing its schema
81
+ // entry) defaults to requiring write, so a read-only token can never slip
82
+ // through the client gate.
83
+ function writeCommandRequiresWriteScope(commandId) {
84
+ return (requiredAccessByWriteCommandId.get(commandId) ?? 'write') === 'write';
85
+ }
86
+
87
+ async function ensureCompatibleRemoteContract(sessionState, transportOptions = {}) {
88
+ const baseUrl = sessionState.session?.transport?.baseUrl;
89
+ let remoteVersion = sessionState.session?.transport?.contractVersion;
90
+ if (sessionState.session?.transport?.uncheckedContract && transportOptions.resolveContract) {
91
+ const remoteContract = await transportOptions.resolveContract();
92
+ sessionState.session.transport = {
93
+ ...(sessionState.session.transport ?? {}),
94
+ contractVersion: remoteContract.contractVersion,
95
+ capabilities: remoteContract.capabilities ?? null,
96
+ uncheckedContract: false
97
+ };
98
+ remoteVersion = remoteContract.contractVersion;
99
+ }
100
+ if (!baseUrl) return;
101
+ if (remoteVersion == null) {
102
+ throw remoteContractMismatchError(remoteVersion);
103
+ }
104
+ if (Number(remoteVersion) !== contractVersion) {
105
+ throw remoteContractMismatchError(remoteVersion);
106
+ }
107
+ }
108
+
109
+ async function throwForbiddenResponse(response, sessionState, commandId) {
110
+ const payload = await response.json().catch(() => null);
111
+ const requiredAccess = requiredAccessForCommand(commandId);
112
+ const auth = sessionState.session?.auth;
113
+ const isAgentToken = auth?.type === 'agent-token';
114
+ // A token we know to be write-capable hit a genuine authorization failure, not
115
+ // a scope problem — never mask it as INSUFFICIENT_SCOPE.
116
+ const knownWriteCapable = isAgentToken && auth?.access === 'write';
117
+ // Trust the server's explicit code. Only synthesise a scope error when the
118
+ // server returned no code at all (older/edge paths) for an agent token of
119
+ // unknown-or-read scope hitting a write command — there a 403 can only be a
120
+ // scope problem (the service runs its scope check before any ownership check).
121
+ if (
122
+ payload?.code === 'INSUFFICIENT_SCOPE'
123
+ || (!payload?.code && isAgentToken && !knownWriteCapable && requiredAccess === 'write')
124
+ ) {
125
+ throw insufficientScopeError(payload?.error);
126
+ }
127
+
128
+ const error = new Error(payload?.error ?? `Remote request forbidden (HTTP ${response.status}).`);
129
+ error.code = payload?.code ?? 'REMOTE_FORBIDDEN';
130
+ throw error;
131
+ }
132
+
24
133
  const remoteCommandHandlers = {
25
134
  'session-insights': executeRemoteRead,
26
135
  'session-show': executeRemoteRead,
@@ -29,8 +138,11 @@ const remoteCommandHandlers = {
29
138
  'program-list': executeRemoteRead,
30
139
  'program-summary': executeRemoteRead,
31
140
  'program-detail': executeRemoteRead,
141
+ 'program-progress': executeRemoteRead,
142
+ 'exercise-progress-summary': executeRemoteRead,
32
143
  'cycle-summary-list': executeRemoteRead,
33
144
  'cycle-summary-show': executeRemoteRead,
145
+ 'cycle-progression-summary': executeRemoteRead,
34
146
  'planned-vs-actual': executeRemoteRead,
35
147
  'why-did-this-change': executeRemoteRead,
36
148
  'goals-list': executeRemoteRead,
@@ -38,6 +150,7 @@ const remoteCommandHandlers = {
38
150
  'health-summary': executeRemoteRead,
39
151
  'health-ai': executeRemoteRead,
40
152
  'training-load': executeRemoteRead,
153
+ 'training-profile': executeRemoteRead,
41
154
  'ask-history': executeRemoteRead,
42
155
  'ask-show': executeRemoteRead,
43
156
  'program-share-fetch': executeRemoteRead,
@@ -46,6 +159,21 @@ const remoteCommandHandlers = {
46
159
  'coach-observations-current': executeRemoteRead
47
160
  };
48
161
 
162
+ const REMOTE_ASK_PLAN_TIMEOUT_MS = 30_000;
163
+
164
+ async function fetchWithTimeout(url, init = {}, timeoutMs = REMOTE_ASK_PLAN_TIMEOUT_MS) {
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
167
+ try {
168
+ return await fetch(url, {
169
+ ...init,
170
+ signal: controller.signal
171
+ });
172
+ } finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+
49
177
  async function executeRemoteRead(options, sessionState, normalizedCommand) {
50
178
  const baseUrl = sessionState.session?.transport?.baseUrl;
51
179
  if (baseUrl) {
@@ -81,7 +209,7 @@ async function executeRemoteHttpRead(baseUrl, sessionState, normalizedCommand, o
81
209
  }
82
210
 
83
211
  if (response.status === 403) {
84
- throw authenticationFailedError();
212
+ await throwForbiddenResponse(response, sessionState, normalizedCommand);
85
213
  }
86
214
 
87
215
  if (response.status === 404) {
@@ -122,6 +250,13 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
122
250
  return resolveServiceUrl(baseUrl, '/cli/programs/current');
123
251
  case 'program-detail':
124
252
  return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${encodeURIComponent(options.id)}` : '/cli/programs/active');
253
+ case 'program-progress': {
254
+ const url = resolveServiceUrl(baseUrl, '/cli/programs/progress');
255
+ if (options['program-id']) url.searchParams.set('program-id', options['program-id']);
256
+ if (options.since) url.searchParams.set('since', options.since);
257
+ if (options.limitExercises) url.searchParams.set('limitExercises', options.limitExercises);
258
+ return url;
259
+ }
125
260
  case 'cycle-summary-list': {
126
261
  const cyclesUrl = resolveServiceUrl(baseUrl, '/cli/cycles');
127
262
  if (options['program-id']) {
@@ -131,11 +266,25 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
131
266
  }
132
267
  case 'cycle-summary-show':
133
268
  return resolveServiceUrl(baseUrl, `/cli/cycles/${encodeURIComponent(options.id)}`);
269
+ case 'cycle-progression-summary': {
270
+ const url = resolveServiceUrl(baseUrl, '/cli/cycles/progress');
271
+ if (options['program-id']) url.searchParams.set('program-id', options['program-id']);
272
+ if (options.limit) url.searchParams.set('limit', options.limit);
273
+ return url;
274
+ }
134
275
  case 'exercise-history': {
135
276
  const historyUrl = resolveServiceUrl(baseUrl, '/cli/exercises/history');
136
277
  historyUrl.searchParams.set('name', options.name ?? options.exercise);
137
278
  return historyUrl;
138
279
  }
280
+ case 'exercise-progress-summary': {
281
+ const url = resolveServiceUrl(baseUrl, '/cli/exercises/progress');
282
+ if (options.name ?? options.exercise) url.searchParams.set('name', options.name ?? options.exercise);
283
+ if (options.since) url.searchParams.set('since', options.since);
284
+ if (options['program-id']) url.searchParams.set('program-id', options['program-id']);
285
+ if (options.limit) url.searchParams.set('limit', options.limit);
286
+ return url;
287
+ }
139
288
  case 'records':
140
289
  return resolveServiceUrl(baseUrl, '/cli/records');
141
290
  case 'goals-list':
@@ -153,6 +302,11 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
153
302
  return resolveServiceUrl(baseUrl, '/cli/health/ai');
154
303
  case 'training-load':
155
304
  return resolveServiceUrl(baseUrl, '/cli/training-load');
305
+ case 'training-profile': {
306
+ const url = resolveServiceUrl(baseUrl, '/cli/training-profile');
307
+ if (options.since) url.searchParams.set('since', options.since);
308
+ return url;
309
+ }
156
310
  case 'ask-history': {
157
311
  const askUrl = resolveServiceUrl(baseUrl, '/cli/ask/history');
158
312
  if (options.limit) {
@@ -223,7 +377,8 @@ async function executeRemoteCoachReadTool(toolName, input, sessionState) {
223
377
  body: JSON.stringify(input ?? {})
224
378
  });
225
379
 
226
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
380
+ if (response.status === 401) throw authenticationFailedError();
381
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'coach-read-tool');
227
382
  if (response.status === 404) {
228
383
  const error = new Error(`Unknown coach read tool: ${toolName}`);
229
384
  error.code = 'REMOTE_NOT_FOUND';
@@ -244,6 +399,72 @@ async function executeRemoteCoachReadTool(toolName, input, sessionState) {
244
399
  return executeLocalCoachReadTool(snapshot, toolName, input);
245
400
  }
246
401
 
402
+ async function executeRemoteAskPlan(input, sessionState) {
403
+ const baseUrl = sessionState.session?.transport?.baseUrl;
404
+ if (baseUrl) {
405
+ const endpoint = resolveServiceUrl(baseUrl, '/cli/ask/plan');
406
+ let response;
407
+ try {
408
+ response = await fetchWithTimeout(endpoint, {
409
+ method: 'POST',
410
+ headers: {
411
+ 'Content-Type': 'application/json',
412
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
413
+ },
414
+ body: JSON.stringify(input ?? {})
415
+ });
416
+ } catch (error) {
417
+ if (error?.name === 'AbortError') throw remoteTimeoutError();
418
+ throw error;
419
+ }
420
+
421
+ if (response.status === 401) throw authenticationFailedError();
422
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'plan-ask-interaction');
423
+ if (!response.ok) {
424
+ const payload = await response.json().catch(() => null);
425
+ const error = new Error(payload?.error ?? `Unexpected error from incremnt sync service (HTTP ${response.status}).`);
426
+ error.code = 'REMOTE_HTTP_ERROR';
427
+ throw error;
428
+ }
429
+ return response.json();
430
+ }
431
+
432
+ const fixturePath = sessionState.session?.transport?.fixturePath;
433
+ if (!fixturePath) throw notImplementedError();
434
+ assertValidAskPlanQuestion(input?.question);
435
+ const snapshot = await readSnapshot(fixturePath);
436
+ const exclude = new Set(String(input?.exclude ?? '').split(',').map((item) => item.trim()).filter(Boolean));
437
+ if (input?.coachObservation) {
438
+ const observationId = String(input.coachObservation.id ?? '').trim();
439
+ const snapshotObservation = (snapshot.coachObservations ?? [])
440
+ .find((observation) => (
441
+ String(observation?.id ?? '') === observationId &&
442
+ shouldKeepCurrentCoachObservation(observation, { includeOutcomeHistory: true })
443
+ ));
444
+ const routedContext = snapshotObservation
445
+ ? askObservationFollowUpContext(snapshot, input?.question, snapshotObservation, {
446
+ exclude,
447
+ intent: input.coachObservation.intent
448
+ })
449
+ : askMissingObservationFollowUpContext(snapshot, input?.question, input.coachObservation, {
450
+ exclude,
451
+ intent: input.coachObservation.intent
452
+ });
453
+ return {
454
+ contextBundle: routedContext.contextBundle,
455
+ metadata: routedContext.metadata
456
+ };
457
+ }
458
+ const routedContext = askRoutedContext(snapshot, input?.question, {
459
+ exclude,
460
+ history: sanitizeHistory(input?.history)
461
+ });
462
+ return {
463
+ contextBundle: routedContext.contextBundle,
464
+ metadata: routedContext.metadata
465
+ };
466
+ }
467
+
247
468
  // Build the shape of a write request without executing it.
248
469
  // Returns { method, url, body }. body is a parsed JS object or null.
249
470
  // Used by the --dry-run path. Keep endpoint and body changes in sync with the
@@ -353,6 +574,48 @@ export async function buildWriteRequest(commandId, options, sessionState) {
353
574
  body: {}
354
575
  };
355
576
  }
577
+ case 'coach-observations-outcome': {
578
+ if (!options.id) {
579
+ const error = new Error('--id is required for coach observations outcome.');
580
+ error.code = 'MISSING_OPTION';
581
+ throw error;
582
+ }
583
+ if (!options.status) {
584
+ const error = new Error('--status is required for coach observations outcome.');
585
+ error.code = 'MISSING_OPTION';
586
+ throw error;
587
+ }
588
+ const body = { outcomeStatus: options.status };
589
+ if (options.observedAt) body.outcomeObservedAt = options.observedAt;
590
+ if (options.notes) body.outcomeNotes = options.notes;
591
+ if (options.linkedFollowupObservationId) {
592
+ body.linkedFollowupObservationId = options.linkedFollowupObservationId;
593
+ }
594
+ return {
595
+ method: 'POST',
596
+ url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/outcome`).toString(),
597
+ body
598
+ };
599
+ }
600
+ case 'coach-observations-feedback': {
601
+ if (!options.id) {
602
+ const error = new Error('--id is required for coach observations feedback.');
603
+ error.code = 'MISSING_OPTION';
604
+ throw error;
605
+ }
606
+ if (!options.status) {
607
+ const error = new Error('--status is required for coach observations feedback.');
608
+ error.code = 'MISSING_OPTION';
609
+ throw error;
610
+ }
611
+ const body = { feedbackStatus: options.status };
612
+ if (options.feedbackAt) body.feedbackAt = options.feedbackAt;
613
+ return {
614
+ method: 'POST',
615
+ url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/feedback`).toString(),
616
+ body
617
+ };
618
+ }
356
619
  default: {
357
620
  const error = new Error(`Command ${commandId} does not support --dry-run.`);
358
621
  error.code = 'UNSUPPORTED_DRY_RUN';
@@ -386,7 +649,8 @@ const remoteWriteCommandHandlers = {
386
649
  body: JSON.stringify(proposal)
387
650
  });
388
651
 
389
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
652
+ if (response.status === 401) throw authenticationFailedError();
653
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'programs-propose');
390
654
  if (!response.ok) {
391
655
  const payload = await response.json().catch(() => null);
392
656
  const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
@@ -412,7 +676,8 @@ const remoteWriteCommandHandlers = {
412
676
  }
413
677
  });
414
678
 
415
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
679
+ if (response.status === 401) throw authenticationFailedError();
680
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'programs-proposals');
416
681
  if (!response.ok) {
417
682
  const payload = await response.json().catch(() => null);
418
683
  const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
@@ -443,7 +708,8 @@ const remoteWriteCommandHandlers = {
443
708
  body: JSON.stringify({ status: 'dismissed' })
444
709
  });
445
710
 
446
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
711
+ if (response.status === 401) throw authenticationFailedError();
712
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'proposal-dismiss');
447
713
  if (response.status === 404) {
448
714
  const error = new Error(`Proposal not found: ${options.id}`);
449
715
  error.code = 'REMOTE_NOT_FOUND';
@@ -476,7 +742,8 @@ const remoteWriteCommandHandlers = {
476
742
  }
477
743
  });
478
744
 
479
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
745
+ if (response.status === 401) throw authenticationFailedError();
746
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'program-share-create');
480
747
  if (response.status === 404) {
481
748
  const error = new Error(`Program not found: ${options['program-id']}`);
482
749
  error.code = 'REMOTE_NOT_FOUND';
@@ -507,7 +774,8 @@ const remoteWriteCommandHandlers = {
507
774
  }
508
775
  });
509
776
 
510
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
777
+ if (response.status === 401) throw authenticationFailedError();
778
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'program-share-list');
511
779
  if (!response.ok) {
512
780
  const payload = await response.json().catch(() => null);
513
781
  const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
@@ -544,7 +812,8 @@ const remoteWriteCommandHandlers = {
544
812
  body: JSON.stringify(body)
545
813
  });
546
814
 
547
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
815
+ if (response.status === 401) throw authenticationFailedError();
816
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'increment-score-upload');
548
817
  if (!response.ok) {
549
818
  const payload = await response.json().catch(() => null);
550
819
  const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
@@ -572,7 +841,8 @@ const remoteWriteCommandHandlers = {
572
841
  }
573
842
  });
574
843
 
575
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
844
+ if (response.status === 401) throw authenticationFailedError();
845
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'program-share-revoke');
576
846
  if (response.status === 404) {
577
847
  const error = new Error(`Program share not found: ${options['share-id']}`);
578
848
  error.code = 'REMOTE_NOT_FOUND';
@@ -608,7 +878,8 @@ const remoteWriteCommandHandlers = {
608
878
  body: JSON.stringify(body)
609
879
  });
610
880
 
611
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
881
+ if (response.status === 401) throw authenticationFailedError();
882
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'coach-observations-seen');
612
883
  if (response.status === 404) {
613
884
  const error = new Error(`Coach observation not found: ${options.id}`);
614
885
  error.code = 'REMOTE_NOT_FOUND';
@@ -642,7 +913,54 @@ const remoteWriteCommandHandlers = {
642
913
  body: JSON.stringify({})
643
914
  });
644
915
 
645
- if (response.status === 401 || response.status === 403) throw authenticationFailedError();
916
+ if (response.status === 401) throw authenticationFailedError();
917
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'coach-observations-dismiss');
918
+ if (response.status === 404) {
919
+ const error = new Error(`Coach observation not found: ${options.id}`);
920
+ error.code = 'REMOTE_NOT_FOUND';
921
+ throw error;
922
+ }
923
+ if (!response.ok) {
924
+ const payload = await response.json().catch(() => null);
925
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
926
+ error.code = 'REMOTE_HTTP_ERROR';
927
+ throw error;
928
+ }
929
+ return response.json();
930
+ },
931
+
932
+ 'coach-observations-outcome': async (options, sessionState) => {
933
+ const baseUrl = sessionState.session?.transport?.baseUrl;
934
+ if (!baseUrl) throw notImplementedError();
935
+ if (!options.id) {
936
+ const error = new Error('--id is required for coach observations outcome.');
937
+ error.code = 'MISSING_OPTION';
938
+ throw error;
939
+ }
940
+ if (!options.status) {
941
+ const error = new Error('--status is required for coach observations outcome.');
942
+ error.code = 'MISSING_OPTION';
943
+ throw error;
944
+ }
945
+
946
+ const body = { outcomeStatus: options.status };
947
+ if (options.observedAt) body.outcomeObservedAt = options.observedAt;
948
+ if (options.notes) body.outcomeNotes = options.notes;
949
+ if (options.linkedFollowupObservationId) {
950
+ body.linkedFollowupObservationId = options.linkedFollowupObservationId;
951
+ }
952
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/outcome`);
953
+ const response = await fetch(endpoint, {
954
+ method: 'POST',
955
+ headers: {
956
+ 'Content-Type': 'application/json',
957
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
958
+ },
959
+ body: JSON.stringify(body)
960
+ });
961
+
962
+ if (response.status === 401) throw authenticationFailedError();
963
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'coach-observations-outcome');
646
964
  if (response.status === 404) {
647
965
  const error = new Error(`Coach observation not found: ${options.id}`);
648
966
  error.code = 'REMOTE_NOT_FOUND';
@@ -655,13 +973,133 @@ const remoteWriteCommandHandlers = {
655
973
  throw error;
656
974
  }
657
975
  return response.json();
976
+ },
977
+
978
+ 'coach-observations-feedback': async (options, sessionState) => {
979
+ const baseUrl = sessionState.session?.transport?.baseUrl;
980
+ if (!baseUrl) throw notImplementedError();
981
+ if (!options.id) {
982
+ const error = new Error('--id is required for coach observations feedback.');
983
+ error.code = 'MISSING_OPTION';
984
+ throw error;
985
+ }
986
+ if (!options.status) {
987
+ const error = new Error('--status is required for coach observations feedback.');
988
+ error.code = 'MISSING_OPTION';
989
+ throw error;
990
+ }
991
+
992
+ const body = { feedbackStatus: options.status };
993
+ if (options.feedbackAt) body.feedbackAt = options.feedbackAt;
994
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/feedback`);
995
+ const response = await fetch(endpoint, {
996
+ method: 'POST',
997
+ headers: {
998
+ 'Content-Type': 'application/json',
999
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
1000
+ },
1001
+ body: JSON.stringify(body)
1002
+ });
1003
+
1004
+ if (response.status === 401) throw authenticationFailedError();
1005
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'coach-observations-feedback');
1006
+ if (response.status === 404) {
1007
+ const error = new Error(`Coach observation not found: ${options.id}`);
1008
+ error.code = 'REMOTE_NOT_FOUND';
1009
+ throw error;
1010
+ }
1011
+ if (!response.ok) {
1012
+ const payload = await response.json().catch(() => null);
1013
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
1014
+ error.code = 'REMOTE_HTTP_ERROR';
1015
+ throw error;
1016
+ }
1017
+ return response.json();
1018
+ },
1019
+
1020
+ 'ask': async (options, sessionState) => {
1021
+ const baseUrl = sessionState.session?.transport?.baseUrl;
1022
+ if (!baseUrl) throw notImplementedError();
1023
+ const question = String(options.question ?? '').trim();
1024
+ if (!question) {
1025
+ const error = new Error('--question is required for ask.');
1026
+ error.code = 'MISSING_OPTION';
1027
+ throw error;
1028
+ }
1029
+ if (question.length > 500) {
1030
+ const error = new Error('--question must be 500 characters or fewer.');
1031
+ error.code = 'INVALID_OPTION';
1032
+ throw error;
1033
+ }
1034
+
1035
+ let history;
1036
+ if (options.history) {
1037
+ try {
1038
+ history = JSON.parse(options.history);
1039
+ } catch {
1040
+ const error = new Error('--history must be valid JSON (array of {role, content}).');
1041
+ error.code = 'INVALID_OPTION';
1042
+ throw error;
1043
+ }
1044
+ if (!Array.isArray(history)) {
1045
+ const error = new Error('--history must be a JSON array.');
1046
+ error.code = 'INVALID_OPTION';
1047
+ throw error;
1048
+ }
1049
+ }
1050
+
1051
+ const providedConversationId = String(
1052
+ options['conversation-id'] ?? options.conversationId ?? ''
1053
+ ).trim();
1054
+ const conversationId = providedConversationId || randomUUID();
1055
+
1056
+ const body = { question, conversationId };
1057
+ if (history) body.history = history;
1058
+ if (options.exclude) body.exclude = options.exclude;
1059
+ if (options.tone) body.tone = options.tone;
1060
+
1061
+ const endpoint = resolveServiceUrl(baseUrl, '/cli/ask');
1062
+ const response = await fetch(endpoint, {
1063
+ method: 'POST',
1064
+ headers: {
1065
+ 'Content-Type': 'application/json',
1066
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
1067
+ },
1068
+ body: JSON.stringify(body)
1069
+ });
1070
+
1071
+ if (response.status === 401) throw authenticationFailedError();
1072
+ if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'ask');
1073
+ if (!response.ok) {
1074
+ const payload = await response.json().catch(() => null);
1075
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
1076
+ error.code = 'REMOTE_HTTP_ERROR';
1077
+ throw error;
1078
+ }
1079
+ const payload = await response.json();
1080
+ const responseConversationId = typeof payload?.conversationId === 'string'
1081
+ ? payload.conversationId.trim()
1082
+ : '';
1083
+ return {
1084
+ ...payload,
1085
+ conversationId: responseConversationId || conversationId
1086
+ };
658
1087
  }
659
1088
  };
660
1089
 
661
1090
  export function createRemoteTransport(sessionState, transportOptions = {}) {
1091
+ const auth = sessionState.session?.auth ?? {};
1092
+ const credential = {
1093
+ source: transportOptions.credentialSource ?? auth.source ?? 'session',
1094
+ type: auth.type ?? (auth.accessToken ? 'session' : null),
1095
+ access: auth.access ?? null,
1096
+ expiresAt: auth.expiresAt ?? null,
1097
+ canRefresh: Boolean(auth.accessToken && auth.type !== 'agent-token' && sessionState.session?.transport?.baseUrl)
1098
+ };
662
1099
  return {
663
1100
  kind: 'remote',
664
1101
  account: sessionState.session?.account ?? null,
1102
+ credential,
665
1103
  bootstrap: Boolean(sessionState.session?.transport?.fixturePath || sessionState.session?.transport?.baseUrl),
666
1104
  source: sessionState.session?.transport?.baseUrl
667
1105
  ? 'http'
@@ -672,10 +1110,12 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
672
1110
  contractVersion: sessionState.session?.transport?.contractVersion ?? null,
673
1111
  capabilities: sessionState.session?.transport?.capabilities ?? null,
674
1112
  fixturePath: sessionState.session?.transport?.fixturePath ?? null,
1113
+ contractUnchecked: Boolean(sessionState.session?.transport?.uncheckedContract),
675
1114
  async executeReadCommand(normalizedCommand, options = {}) {
676
1115
  if (transportOptions.expired) {
677
1116
  throw expiredSessionError();
678
1117
  }
1118
+ await ensureCompatibleRemoteContract(sessionState, transportOptions);
679
1119
 
680
1120
  const handler = remoteCommandHandlers[normalizedCommand];
681
1121
  if (!handler) {
@@ -690,13 +1130,26 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
690
1130
  if (transportOptions.expired) {
691
1131
  throw expiredSessionError();
692
1132
  }
1133
+ await ensureCompatibleRemoteContract(sessionState, transportOptions);
693
1134
 
694
1135
  return executeRemoteCoachReadTool(toolName, input, sessionState);
695
1136
  },
1137
+ async planAskInteraction(input = {}) {
1138
+ if (transportOptions.expired) {
1139
+ throw expiredSessionError();
1140
+ }
1141
+ await ensureCompatibleRemoteContract(sessionState, transportOptions);
1142
+
1143
+ return executeRemoteAskPlan(input, sessionState);
1144
+ },
696
1145
  async executeWriteCommand(normalizedCommand, options = {}) {
697
1146
  if (transportOptions.expired) {
698
1147
  throw expiredSessionError();
699
1148
  }
1149
+ if (credential.type === 'agent-token' && credential.access === 'read' && writeCommandRequiresWriteScope(normalizedCommand)) {
1150
+ throw insufficientScopeError();
1151
+ }
1152
+ await ensureCompatibleRemoteContract(sessionState, transportOptions);
700
1153
 
701
1154
  const handler = remoteWriteCommandHandlers[normalizedCommand];
702
1155
  if (!handler) {