incremnt 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,9 @@ incremnt login --session-file ~/Downloads/session.json
67
67
  | `increment-score current` | Latest Increment Score summary with components, drivers, trend, and data-quality flags |
68
68
  | `increment-score history` | Historical Increment Score snapshots |
69
69
  | `increment-score upload --file <file>` | Upload Increment Score snapshots |
70
+ | `coach observations list` | Current persisted coach observations |
71
+ | `coach observations seen --id <id>` | Mark a coach observation as seen |
72
+ | `coach observations dismiss --id <id>` | Dismiss an observation and suppress matching follow-ups |
70
73
  | `programs propose --file <file>` | Submit a program proposal |
71
74
  | `programs proposals` | List proposals |
72
75
  | `programs proposal dismiss --id <id>` | Dismiss a proposal |
@@ -142,7 +145,7 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
142
145
 
143
146
  The MCP server exposes two tool families:
144
147
 
145
- - Command-shaped tools from the public CLI contract, including sessions, programs, cycles, goals, health, training load, ask history/show, `increment-score-current`, `increment-score-history`, `increment-score-upload`, program proposals, and program shares.
148
+ - Command-shaped tools from the public CLI contract, including sessions, programs, cycles, goals, health, training load, ask history/show, `increment-score-current`, `increment-score-history`, `increment-score-upload`, `coach-observations-current`, program proposals, and program shares.
146
149
  - Typed coach read tools for agent-native context retrieval, including `get_increment_score`, `get_recent_sessions`, `get_exercise_history`, `get_next_session`, `get_readiness_snapshot`, `get_body_weight_snapshot`, `get_goal_status`, and `get_records`.
147
150
 
148
151
  `get_increment_score` returns the same privacy-safe score summary as `increment-score current`: score, snapshot timestamp, formula version, data tier, component scores, positive/negative drivers, day-over-day delta, recent trend, and data-quality flags. It does not expose raw HealthKit values.
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: incremnt-cli
3
- description: Query a user's strength training data (sessions, programs, records, Increment Score, health vitals) from the incremnt iOS app via CLI or MCP.
3
+ description: Query a user's strength training data (sessions, programs, records, Increment Score, coach observations, health vitals) from the incremnt iOS app via CLI or MCP.
4
4
  binary: incremnt
5
5
  mcp_binary: incremnt-mcp
6
6
  contract_command: incremnt contract
@@ -20,7 +20,7 @@ contract_command: incremnt contract
20
20
 
21
21
  ## Write-flow commands
22
22
 
23
- Five commands mutate state. Two additional lookup commands are listed in `writeSchema` because they support write flows. All require an authenticated session.
23
+ Seven commands mutate state. Two additional lookup commands are listed in `writeSchema` because they support write flows. All require an authenticated session.
24
24
 
25
25
  Append `--dry-run` to any mutating command to preview the HTTP request as `{ dryRun: true, command, request: { method, url, body } }` without sending it. Prefer this before any irreversible action.
26
26
 
@@ -33,6 +33,8 @@ Append `--dry-run` to any mutating command to preview the HTTP request as `{ dry
33
33
  | `programs share list --program-id <id>` | Read-only lookup | Use before revoking a share. `share-id` is not the public token. |
34
34
  | `programs share revoke --share-id <id>` | Irreversible | `share-id` is from `programs share list`, not the public token. |
35
35
  | `increment-score upload --file <f>` | Overwrites by timestamp | File shape: `{ snapshots: [...] }`. Same `snapshot_at` replaces prior data. |
36
+ | `coach observations seen --id <id>` | Reversible by later lifecycle updates | IDs come from `coach observations list`. CLI/API-only; not exposed as an MCP write. |
37
+ | `coach observations dismiss --id <id>` | Suppresses matching follow-ups for the server window | IDs come from `coach observations list`. CLI/API-only; not exposed as an MCP write. |
36
38
 
37
39
  Confirm with the user before any mutating command unless they explicitly authorised the specific action.
38
40
 
@@ -42,6 +44,7 @@ Confirm with the user before any mutating command unless they explicitly authori
42
44
  - `records` is the cheapest way to discover the canonical exercise names a user has actually trained. Use it before composing a `programs propose` payload.
43
45
  - `exercises history --name "Bench Press"` uses canonical synonym matching — it finds `Barbell Bench Press` without pulling in incline/dumbbell variants.
44
46
  - `increment-score current` returns a privacy-safe summary only (score, components, drivers, trend, data-quality flags) — no raw HealthKit values.
47
+ - `coach observations list` returns persisted coach insights generated from training patterns. MCP exposes this read surface as `coach-observations-current`; seen/dismiss writes stay CLI/API-only.
45
48
  - `health summary --days <n>` and `training load` give physiological context (resting HR, HRV, ATL/CTL/TSB).
46
49
 
47
50
  ## Input validation (already enforced)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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 = 14;
1
+ export const contractVersion = 15;
2
2
 
3
3
  export const capabilities = {
4
4
  readOnly: false,
@@ -203,6 +203,16 @@ export const commandSchema = [
203
203
  { name: 'to', type: 'string', required: false, description: 'Latest snapshot_at (ISO 8601)' },
204
204
  { name: 'limit', type: 'number', required: false, description: 'Max snapshots to return (default 200, max 1000)' }
205
205
  ]
206
+ },
207
+ {
208
+ command: 'coach observations list',
209
+ id: 'coach-observations-current',
210
+ description: 'List current persisted coach observations',
211
+ supportsFields: true,
212
+ agentNotes: 'Read-only persisted insight surface. Returns generated/seen observations with evidence and lifecycle status. Use IDs from this command for CLI seen/dismiss actions.',
213
+ options: [
214
+ { name: 'limit', type: 'number', required: false, description: 'Max observations to return (default 5, max 20)' }
215
+ ]
206
216
  }
207
217
  ];
208
218
 
@@ -280,6 +290,31 @@ export const writeCommandSchema = [
280
290
  options: [
281
291
  { name: 'file', type: 'string', format: 'path', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
282
292
  ]
293
+ },
294
+ {
295
+ command: 'coach observations seen',
296
+ id: 'coach-observations-seen',
297
+ description: 'Mark a coach observation as seen',
298
+ usage: 'coach observations seen --id <observation-id>',
299
+ dryRun: true,
300
+ mcp: false,
301
+ agentNotes: 'Mutation. IDs come from coach observations list. This is intentionally CLI/API-only for now. Pass --dry-run to preview the request without sending.',
302
+ options: [
303
+ { name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' },
304
+ { name: 'seenAt', type: 'string', required: false, description: 'Optional ISO timestamp to record as seenAt' }
305
+ ]
306
+ },
307
+ {
308
+ command: 'coach observations dismiss',
309
+ id: 'coach-observations-dismiss',
310
+ description: 'Dismiss a coach observation and suppress matching follow-ups',
311
+ usage: 'coach observations dismiss --id <observation-id>',
312
+ dryRun: true,
313
+ mcp: false,
314
+ agentNotes: 'Mutation. IDs come from coach observations list. Creates the server-side suppression window for the same observation pattern. This is intentionally CLI/API-only for now. Pass --dry-run to preview the request without sending.',
315
+ options: [
316
+ { name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' }
317
+ ]
283
318
  }
284
319
  ];
285
320
 
package/src/format.js CHANGED
@@ -744,6 +744,36 @@ function formatIncrementScoreUpload(payload) {
744
744
  return ` Uploaded ${chalk.bold(String(inserted))} Increment Score snapshot${inserted === 1 ? '' : 's'}.`;
745
745
  }
746
746
 
747
+ function formatCoachObservationsCurrent(payload) {
748
+ const observations = payload?.observations;
749
+ if (!Array.isArray(observations) || observations.length === 0) {
750
+ return 'No current coach observations found.';
751
+ }
752
+
753
+ const lines = [header('COACH OBSERVATIONS'), ''];
754
+ for (const observation of observations) {
755
+ const status = observation.status ? `${dimDot()}${chalk.dim(observation.status)}` : '';
756
+ const confidenceValue = Number(observation.confidence);
757
+ const confidence = Number.isFinite(confidenceValue)
758
+ ? `${dimDot()}${chalk.dim(`confidence ${confidenceValue.toFixed(2)}`)}`
759
+ : '';
760
+ lines.push(` ${chalk.bold(observation.title ?? observation.kind ?? 'Observation')}${status}${confidence}`);
761
+ if (observation.summary) lines.push(` ${observation.summary}`);
762
+ if (observation.actionText) lines.push(` ${chalk.dim('Action')} ${observation.actionText}`);
763
+ if (observation.id) lines.push(` ${chalk.dim(observation.id)}`);
764
+ lines.push('');
765
+ }
766
+
767
+ while (lines.at(-1) === '') lines.pop();
768
+ return lines.join('\n');
769
+ }
770
+
771
+ function formatCoachObservationMutation(payload) {
772
+ const observation = payload?.observation;
773
+ if (!observation) return 'Coach observation not found.';
774
+ return ` Coach observation ${chalk.bold(observation.id)} is ${chalk.bold(observation.status)}.`;
775
+ }
776
+
747
777
  // --- Main export ---
748
778
 
749
779
  export function formatHelp(opts = {}) {
@@ -815,7 +845,10 @@ export function formatPretty(command, payload) {
815
845
  'proposal-dismiss': formatProposalDismissed,
816
846
  'increment-score-current': formatIncrementScoreCurrent,
817
847
  'increment-score-history': formatIncrementScoreHistory,
818
- 'increment-score-upload': formatIncrementScoreUpload
848
+ 'increment-score-upload': formatIncrementScoreUpload,
849
+ 'coach-observations-current': formatCoachObservationsCurrent,
850
+ 'coach-observations-seen': formatCoachObservationMutation,
851
+ 'coach-observations-dismiss': formatCoachObservationMutation
819
852
  }[command];
820
853
 
821
854
  return formatter ? formatter(payload) : null;
package/src/mcp.js CHANGED
@@ -68,7 +68,7 @@ export function registerMcpTools(server, {
68
68
  readSessionStateFn = readSessionState,
69
69
  createTransportFn = createTransport
70
70
  } = {}) {
71
- for (const cmd of [...commandSchema, ...writeCommandSchema]) {
71
+ for (const cmd of [...commandSchema, ...writeCommandSchema].filter((entry) => entry.mcp !== false)) {
72
72
  const description = cmd.agentNotes
73
73
  ? `${cmd.description}\n\nNotes for agents:\n${cmd.agentNotes}`
74
74
  : cmd.description;
package/src/queries.js CHANGED
@@ -3610,7 +3610,42 @@ function askToolMetadata(tools = [], provenance = []) {
3610
3610
  };
3611
3611
  }
3612
3612
 
3613
- export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null } = {}) {
3613
+ function appendCoachObservationsContextBeforeExcludeNote(lines, observations, exclude = new Set()) {
3614
+ if (exclude.has('coach_observations')) return [];
3615
+ const usable = (Array.isArray(observations) ? observations : [])
3616
+ .filter((observation) => observation?.id && observation?.summary)
3617
+ .slice(0, 3);
3618
+ if (usable.length === 0) return [];
3619
+
3620
+ const note = buildExcludeNote(exclude);
3621
+ const noteAtEnd = note && lines.at(-1) === note;
3622
+ if (noteAtEnd) {
3623
+ lines.pop();
3624
+ if (lines.at(-1) === '') lines.pop();
3625
+ }
3626
+ const section = [
3627
+ '',
3628
+ 'Coach observations (derived from training data, not user-stated facts):'
3629
+ ];
3630
+ for (const observation of usable) {
3631
+ const parts = [
3632
+ `[${observation.kind ?? 'observation'}] ${observation.summary}`,
3633
+ observation.actionText ? `Action: ${observation.actionText}` : null,
3634
+ observation.sourceComponent ? `component=${observation.sourceComponent}` : null,
3635
+ `confidence=${Number(observation.confidence ?? 0).toFixed(2)}`,
3636
+ `observation-id=${observation.id}`
3637
+ ].filter(Boolean);
3638
+ section.push(` ${parts.join(' ')}`);
3639
+ }
3640
+ lines.push(...section);
3641
+ if (noteAtEnd) {
3642
+ lines.push('');
3643
+ lines.push(note);
3644
+ }
3645
+ return usable.map((observation) => observation.id);
3646
+ }
3647
+
3648
+ export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null, coachObservations = null } = {}) {
3614
3649
  const { route, namedExercises } = routeAskQuestion(snapshot, question);
3615
3650
  let effectiveRoute = route;
3616
3651
  let fallbackRoute = null;
@@ -3657,6 +3692,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3657
3692
  const factLines = built.context.split('\n');
3658
3693
  const includedFacts = rankedCoachFactsForAsk(snapshot, question, effectiveRoute, { facts: coachFacts });
3659
3694
  const includedCoachFactIds = appendCoachFactsContextBeforeExcludeNote(factLines, includedFacts, exclude);
3695
+ const includedCoachObservationIds = appendCoachObservationsContextBeforeExcludeNote(factLines, coachObservations, exclude);
3660
3696
  const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
3661
3697
  const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {
3662
3698
  const sourceSessionId = String(fact.sourceSessionId ?? '');
@@ -3666,7 +3702,11 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3666
3702
  }).filter(Boolean));
3667
3703
  built = {
3668
3704
  context: factLines.join('\n'),
3669
- sections: includedFacts.length > 0 ? [...built.sections, 'coach_facts'] : built.sections
3705
+ sections: [
3706
+ ...built.sections,
3707
+ ...(includedFacts.length > 0 ? ['coach_facts'] : []),
3708
+ ...(includedCoachObservationIds.length > 0 ? ['coach_observations'] : [])
3709
+ ]
3670
3710
  };
3671
3711
 
3672
3712
  return {
@@ -3683,6 +3723,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3683
3723
  coachFactIds: includedCoachFactIds,
3684
3724
  coachFactKinds: includedCoachFactKinds,
3685
3725
  coachFactSources: includedCoachFactSources,
3726
+ includedCoachObservationIds,
3727
+ coachObservationIds: includedCoachObservationIds,
3686
3728
  contextCharCount: built.context.length,
3687
3729
  ...toolMetadata
3688
3730
  }
package/src/remote.js CHANGED
@@ -42,7 +42,8 @@ const remoteCommandHandlers = {
42
42
  'ask-show': executeRemoteRead,
43
43
  'program-share-fetch': executeRemoteRead,
44
44
  'increment-score-current': executeRemoteRead,
45
- 'increment-score-history': executeRemoteRead
45
+ 'increment-score-history': executeRemoteRead,
46
+ 'coach-observations-current': executeRemoteRead
46
47
  };
47
48
 
48
49
  async function executeRemoteRead(options, sessionState, normalizedCommand) {
@@ -175,6 +176,11 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
175
176
  if (options.limit) url.searchParams.set('limit', options.limit);
176
177
  return url;
177
178
  }
179
+ case 'coach-observations-current': {
180
+ const url = resolveServiceUrl(baseUrl, '/cli/coach-observations/current');
181
+ if (options.limit) url.searchParams.set('limit', options.limit);
182
+ return url;
183
+ }
178
184
  default:
179
185
  return resolveServiceUrl(baseUrl, '/');
180
186
  }
@@ -321,6 +327,32 @@ export async function buildWriteRequest(commandId, options, sessionState) {
321
327
  body
322
328
  };
323
329
  }
330
+ case 'coach-observations-seen': {
331
+ if (!options.id) {
332
+ const error = new Error('--id is required for coach observations seen.');
333
+ error.code = 'MISSING_OPTION';
334
+ throw error;
335
+ }
336
+ const body = {};
337
+ if (options.seenAt) body.seenAt = options.seenAt;
338
+ return {
339
+ method: 'POST',
340
+ url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/seen`).toString(),
341
+ body
342
+ };
343
+ }
344
+ case 'coach-observations-dismiss': {
345
+ if (!options.id) {
346
+ const error = new Error('--id is required for coach observations dismiss.');
347
+ error.code = 'MISSING_OPTION';
348
+ throw error;
349
+ }
350
+ return {
351
+ method: 'POST',
352
+ url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/dismiss`).toString(),
353
+ body: {}
354
+ };
355
+ }
324
356
  default: {
325
357
  const error = new Error(`Command ${commandId} does not support --dry-run.`);
326
358
  error.code = 'UNSUPPORTED_DRY_RUN';
@@ -553,6 +585,76 @@ const remoteWriteCommandHandlers = {
553
585
  throw error;
554
586
  }
555
587
  return response.json();
588
+ },
589
+
590
+ 'coach-observations-seen': async (options, sessionState) => {
591
+ const baseUrl = sessionState.session?.transport?.baseUrl;
592
+ if (!baseUrl) throw notImplementedError();
593
+ if (!options.id) {
594
+ const error = new Error('--id is required for coach observations seen.');
595
+ error.code = 'MISSING_OPTION';
596
+ throw error;
597
+ }
598
+
599
+ const body = {};
600
+ if (options.seenAt) body.seenAt = options.seenAt;
601
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/seen`);
602
+ const response = await fetch(endpoint, {
603
+ method: 'POST',
604
+ headers: {
605
+ 'Content-Type': 'application/json',
606
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
607
+ },
608
+ body: JSON.stringify(body)
609
+ });
610
+
611
+ if (response.status === 401 || response.status === 403) throw authenticationFailedError();
612
+ if (response.status === 404) {
613
+ const error = new Error(`Coach observation not found: ${options.id}`);
614
+ error.code = 'REMOTE_NOT_FOUND';
615
+ throw error;
616
+ }
617
+ if (!response.ok) {
618
+ const payload = await response.json().catch(() => null);
619
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
620
+ error.code = 'REMOTE_HTTP_ERROR';
621
+ throw error;
622
+ }
623
+ return response.json();
624
+ },
625
+
626
+ 'coach-observations-dismiss': async (options, sessionState) => {
627
+ const baseUrl = sessionState.session?.transport?.baseUrl;
628
+ if (!baseUrl) throw notImplementedError();
629
+ if (!options.id) {
630
+ const error = new Error('--id is required for coach observations dismiss.');
631
+ error.code = 'MISSING_OPTION';
632
+ throw error;
633
+ }
634
+
635
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/dismiss`);
636
+ const response = await fetch(endpoint, {
637
+ method: 'POST',
638
+ headers: {
639
+ 'Content-Type': 'application/json',
640
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
641
+ },
642
+ body: JSON.stringify({})
643
+ });
644
+
645
+ if (response.status === 401 || response.status === 403) throw authenticationFailedError();
646
+ if (response.status === 404) {
647
+ const error = new Error(`Coach observation not found: ${options.id}`);
648
+ error.code = 'REMOTE_NOT_FOUND';
649
+ throw error;
650
+ }
651
+ if (!response.ok) {
652
+ const payload = await response.json().catch(() => null);
653
+ const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
654
+ error.code = 'REMOTE_HTTP_ERROR';
655
+ throw error;
656
+ }
657
+ return response.json();
556
658
  }
557
659
  };
558
660
 
@@ -22,6 +22,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
22
22
  'weekly-checkin-ack': 30,
23
23
  'weekly-checkin-start': 10,
24
24
  'weekly-digest-current': 30,
25
+ 'coach-observations-current': 30,
26
+ 'coach-observations-seen': 30,
27
+ 'coach-observations-dismiss': 30,
25
28
  'dev-login': 10,
26
29
  'device-start': 20,
27
30
  'device-poll': 300,
@@ -682,6 +685,11 @@ function createRateLimiter({
682
685
  };
683
686
  }
684
687
 
688
+ function parseLimit(value, { defaultValue, max }) {
689
+ const parsed = value ? Number.parseInt(value, 10) : defaultValue;
690
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : defaultValue;
691
+ }
692
+
685
693
  function routeRequest(url, method) {
686
694
  const pathname = url.pathname;
687
695
 
@@ -1055,6 +1063,22 @@ function routeRequest(url, method) {
1055
1063
  return { command: 'weekly-digest-current', options: {} };
1056
1064
  }
1057
1065
 
1066
+ if (pathname === '/cli/coach-observations/current') {
1067
+ return { command: 'coach-observations-current', options: { limit: url.searchParams.get('limit') ?? undefined } };
1068
+ }
1069
+
1070
+ {
1071
+ const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
1072
+ if (coachObservationActionMatch) {
1073
+ return {
1074
+ command: `coach-observations-${coachObservationActionMatch[2]}`,
1075
+ options: {
1076
+ id: decodeURIComponent(coachObservationActionMatch[1])
1077
+ }
1078
+ };
1079
+ }
1080
+ }
1081
+
1058
1082
  if (pathname === '/cli/health/ai') {
1059
1083
  return {
1060
1084
  command: 'health-ai',
@@ -1512,6 +1536,27 @@ async function readJsonBody(request) {
1512
1536
  }
1513
1537
  }
1514
1538
 
1539
+ async function readOptionalJsonBody(request) {
1540
+ const chunks = [];
1541
+ let totalSize = 0;
1542
+ for await (const chunk of request) {
1543
+ totalSize += chunk.length;
1544
+ if (totalSize > MAX_BODY_BYTES) {
1545
+ throw new Error('Request body too large.');
1546
+ }
1547
+ chunks.push(chunk);
1548
+ }
1549
+
1550
+ const raw = Buffer.concat(chunks).toString('utf8');
1551
+ if (!raw.trim()) return {};
1552
+
1553
+ try {
1554
+ return JSON.parse(raw);
1555
+ } catch {
1556
+ throw new Error('Invalid JSON in request body.');
1557
+ }
1558
+ }
1559
+
1515
1560
  async function readUrlEncodedBody(request) {
1516
1561
  const chunks = [];
1517
1562
  let totalSize = 0;
@@ -1992,6 +2037,9 @@ export function createSyncServiceRequestHandler({
1992
2037
  insertScoreSnapshotsForAccount = null,
1993
2038
  listScoreSnapshotsForAccount = null,
1994
2039
  getCurrentWeeklyScoreDigestForAccount = null,
2040
+ listCurrentCoachObservationsForAccount = null,
2041
+ markCoachObservationSeenForAccount = null,
2042
+ dismissCoachObservationForAccount = null,
1995
2043
  // Social
1996
2044
  social = null,
1997
2045
  onError = null
@@ -3496,6 +3544,92 @@ export function createSyncServiceRequestHandler({
3496
3544
  return;
3497
3545
  }
3498
3546
 
3547
+ if (route.command === 'coach-observations-current') {
3548
+ if (request.method !== 'GET') {
3549
+ methodNotAllowed(response, 'Use GET for /cli/coach-observations/current.');
3550
+ return;
3551
+ }
3552
+ if (!listCurrentCoachObservationsForAccount) {
3553
+ json(response, 503, { error: 'Coach observations not available' });
3554
+ return;
3555
+ }
3556
+ try {
3557
+ const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
3558
+ const observations = await listCurrentCoachObservationsForAccount(account, {
3559
+ limit
3560
+ });
3561
+ json(response, 200, { observations: observations ?? [] });
3562
+ } catch (err) {
3563
+ console.error('Coach observations read error:', err.message);
3564
+ json(response, 500, { error: 'Failed to read coach observations' });
3565
+ }
3566
+ return;
3567
+ }
3568
+
3569
+ if (route.command === 'coach-observations-seen') {
3570
+ if (request.method !== 'POST') {
3571
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/seen.');
3572
+ return;
3573
+ }
3574
+ if (!markCoachObservationSeenForAccount) {
3575
+ json(response, 503, { error: 'Coach observations not available' });
3576
+ return;
3577
+ }
3578
+ let body = {};
3579
+ try {
3580
+ body = await readOptionalJsonBody(request);
3581
+ } catch {
3582
+ badRequest(response, 'Invalid request body.');
3583
+ return;
3584
+ }
3585
+ const seenAt = body?.seenAt ? new Date(body.seenAt) : null;
3586
+ if (seenAt && Number.isNaN(seenAt.getTime())) {
3587
+ badRequest(response, 'Invalid seenAt.');
3588
+ return;
3589
+ }
3590
+ try {
3591
+ const observation = await markCoachObservationSeenForAccount(account, route.options.id, { seenAt });
3592
+ if (!observation) {
3593
+ notFound(response, 'Observation not found');
3594
+ return;
3595
+ }
3596
+ json(response, 200, { observation });
3597
+ } catch (err) {
3598
+ console.error('Coach observation seen error:', err.message);
3599
+ json(response, 500, { error: 'Failed to mark observation seen' });
3600
+ }
3601
+ return;
3602
+ }
3603
+
3604
+ if (route.command === 'coach-observations-dismiss') {
3605
+ if (request.method !== 'POST') {
3606
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/dismiss.');
3607
+ return;
3608
+ }
3609
+ if (!dismissCoachObservationForAccount) {
3610
+ json(response, 503, { error: 'Coach observations not available' });
3611
+ return;
3612
+ }
3613
+ try {
3614
+ await readOptionalJsonBody(request);
3615
+ } catch {
3616
+ badRequest(response, 'Invalid request body.');
3617
+ return;
3618
+ }
3619
+ try {
3620
+ const observation = await dismissCoachObservationForAccount(account, route.options.id, {});
3621
+ if (!observation) {
3622
+ notFound(response, 'Observation not found');
3623
+ return;
3624
+ }
3625
+ json(response, 200, { observation });
3626
+ } catch (err) {
3627
+ console.error('Coach observation dismiss error:', err.message);
3628
+ json(response, 500, { error: 'Failed to dismiss observation' });
3629
+ }
3630
+ return;
3631
+ }
3632
+
3499
3633
  let snapshot;
3500
3634
  try {
3501
3635
  snapshot = loadSnapshotForAccount
@@ -4166,9 +4300,17 @@ export function createSyncServiceRequestHandler({
4166
4300
  history: enrichedSnapshots
4167
4301
  };
4168
4302
  }
4303
+ let coachObservations = [];
4304
+ if (listCurrentCoachObservationsForAccount) {
4305
+ try {
4306
+ coachObservations = await listCurrentCoachObservationsForAccount(account, { limit: 3 }) ?? [];
4307
+ } catch (observationErr) {
4308
+ console.error('Coach observations read error (ask):', observationErr.message);
4309
+ }
4310
+ }
4169
4311
 
4170
4312
  const routedContext = queries.askRoutedContext
4171
- ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4313
+ ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations })
4172
4314
  : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4173
4315
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4174
4316
  const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
@@ -4198,7 +4340,8 @@ export function createSyncServiceRequestHandler({
4198
4340
  historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4199
4341
  coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4200
4342
  coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4201
- coachFactKinds: routedContext.metadata?.coachFactKinds ?? []
4343
+ coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
4344
+ coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
4202
4345
  }
4203
4346
  });
4204
4347
 
@@ -4255,6 +4398,15 @@ export function createSyncServiceRequestHandler({
4255
4398
  });
4256
4399
  });
4257
4400
  }
4401
+ if (markCoachObservationSeenForAccount) {
4402
+ for (const observationId of routedContext.metadata?.includedCoachObservationIds ?? []) {
4403
+ try {
4404
+ await markCoachObservationSeenForAccount(account, observationId, {});
4405
+ } catch (seenErr) {
4406
+ console.error('Failed to mark coach observation seen:', seenErr.message);
4407
+ }
4408
+ }
4409
+ }
4258
4410
  const updatedUserTurns = updatedMessages.filter((m) => m.role === 'user').length;
4259
4411
  if (
4260
4412
  persistedKind === 'weekly-checkin' &&