incremnt 0.5.0 → 0.6.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/README.md CHANGED
@@ -67,6 +67,7 @@ incremnt login --session-file ~/Downloads/session.json
67
67
  | `health summary` / `health ai` | Health metrics and AI summary |
68
68
  | `training load` | ATL/CTL/TSB and workload context |
69
69
  | `ask history` / `ask show --id <id>` | Coach conversation history |
70
+ | `increment-score current` | Latest Increment Score summary with components, drivers, trend, and data-quality flags |
70
71
  | `increment-score history` | Historical Increment Score snapshots |
71
72
  | `increment-score upload --file <file>` | Upload Increment Score snapshots |
72
73
  | `programs propose --file <file>` | Submit a program proposal |
@@ -121,7 +122,7 @@ incremnt login --session-file ~/Downloads/session.json
121
122
 
122
123
  ## MCP Server
123
124
 
124
- The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same read queries and program proposal commands as tools for AI assistants like Claude.
125
+ The package includes an [MCP](https://modelcontextprotocol.io) server for AI assistants like Claude and Codex.
125
126
 
126
127
  Run `incremnt mcp install` to auto-register the server with Claude Desktop, Claude Code, and Codex CLI (see [Setup](#setup) above).
127
128
 
@@ -142,7 +143,12 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
142
143
 
143
144
  ### MCP tool surface
144
145
 
145
- The MCP server exposes the same read/write contract as the CLI command surface, including sessions, programs, cycles, goals, health, training load, ask history/show, Increment Score history/upload, program proposal and share workflows, plus typed coach read tools (e.g. `get_increment_score`).
146
+ The MCP server exposes two tool families:
147
+
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`, program proposals, and program shares.
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`.
150
+
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.
146
152
 
147
153
  You can inspect the exact machine-readable contract at any time:
148
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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 = 10;
1
+ export const contractVersion = 11;
2
2
 
3
3
  export const capabilities = {
4
4
  readOnly: false,
@@ -162,6 +162,14 @@ export const commandSchema = [
162
162
  { name: 'token', type: 'string', required: true, description: 'Program share token' }
163
163
  ]
164
164
  },
165
+ {
166
+ command: 'increment-score current',
167
+ id: 'increment-score-current',
168
+ description: 'Show the latest Increment Score snapshot summary',
169
+ options: [
170
+ { name: 'historyDays', type: 'number', required: false, description: 'Recent score history window (default 14, max 60)' }
171
+ ]
172
+ },
165
173
  {
166
174
  command: 'increment-score history',
167
175
  id: 'increment-score-history',
package/src/format.js CHANGED
@@ -689,6 +689,50 @@ function formatIncrementScoreHistory(payload) {
689
689
  return lines.join('\n');
690
690
  }
691
691
 
692
+ function formatIncrementScoreCurrent(payload) {
693
+ if (!payload?.available) {
694
+ return 'No Increment Score snapshots found.';
695
+ }
696
+
697
+ const lines = [
698
+ ` ${chalk.bold('INCREMENT SCORE')}${dimDot()}${chalk.bold(String(payload.score))}${dimDot()}${chalk.dim(payload.dataTier ?? '')}`,
699
+ ''
700
+ ];
701
+
702
+ if (payload.snapshotAt) lines.push(` Snapshot ${chalk.dim(formatShortDate(payload.snapshotAt))}`);
703
+ if (payload.formulaVersion) lines.push(` Formula ${chalk.dim(payload.formulaVersion)}`);
704
+ if (payload.dayOverDayDelta != null) {
705
+ const sign = payload.dayOverDayDelta > 0 ? '+' : '';
706
+ lines.push(` Delta ${chalk.dim(`${sign}${payload.dayOverDayDelta}`)}`);
707
+ }
708
+ if (lines.at(-1) !== '') lines.push('');
709
+
710
+ if (payload.components && typeof payload.components === 'object' && Object.keys(payload.components).length > 0) {
711
+ lines.push(` ${chalk.bold('Components')}`);
712
+ for (const [name, value] of Object.entries(payload.components)) {
713
+ lines.push(` ${name.padEnd(12)} ${chalk.dim(String(value))}`);
714
+ }
715
+ lines.push('');
716
+ }
717
+
718
+ const drivers = (label, list) => {
719
+ if (!Array.isArray(list) || list.length === 0) return;
720
+ lines.push(` ${chalk.bold(label)}`);
721
+ for (const text of list.slice(0, 3)) lines.push(` ${text}`);
722
+ lines.push('');
723
+ };
724
+ drivers('Top positive drivers', payload.topPositiveDrivers);
725
+ drivers('Top negative drivers', payload.topNegativeDrivers);
726
+
727
+ if (Array.isArray(payload.dataQualityNotes) && payload.dataQualityNotes.length > 0) {
728
+ lines.push(` ${chalk.bold('Data quality')}`);
729
+ for (const note of payload.dataQualityNotes) lines.push(` ${note}`);
730
+ }
731
+
732
+ while (lines.at(-1) === '') lines.pop();
733
+ return lines.join('\n');
734
+ }
735
+
692
736
  function formatIncrementScoreUpload(payload) {
693
737
  const inserted = payload?.inserted ?? payload?.count ?? 0;
694
738
  return ` Uploaded ${chalk.bold(String(inserted))} Increment Score snapshot${inserted === 1 ? '' : 's'}.`;
@@ -763,6 +807,7 @@ export function formatPretty(command, payload) {
763
807
  'programs-propose': formatProposalCreated,
764
808
  'programs-proposals': formatProposalsList,
765
809
  'proposal-dismiss': formatProposalDismissed,
810
+ 'increment-score-current': formatIncrementScoreCurrent,
766
811
  'increment-score-history': formatIncrementScoreHistory,
767
812
  'increment-score-upload': formatIncrementScoreUpload
768
813
  }[command];
package/src/mcp.js CHANGED
@@ -8,6 +8,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
9
  import { z } from 'zod';
10
10
  import { commandSchema, writeCommands, writeCommandSchema } from './contract.js';
11
+ import { listCoachReadTools } from './queries.js';
11
12
  import { readSessionState } from './state.js';
12
13
  import { createTransport } from './transport.js';
13
14
 
@@ -26,6 +27,32 @@ function commandShape(cmd) {
26
27
  return shape;
27
28
  }
28
29
 
30
+ function coachToolShape(tool) {
31
+ const shape = {};
32
+ const props = tool.inputSchema?.properties ?? {};
33
+ const required = new Set(tool.inputSchema?.required ?? []);
34
+
35
+ for (const [name, schema] of Object.entries(props)) {
36
+ let field;
37
+ if (schema.type === 'integer' || schema.type === 'number') {
38
+ field = z.number();
39
+ } else if (schema.type === 'boolean') {
40
+ field = z.boolean();
41
+ } else if (schema.type === 'array') {
42
+ field = z.array(z.any());
43
+ } else if (schema.type === 'object') {
44
+ field = z.record(z.string(), z.any());
45
+ } else {
46
+ field = z.string();
47
+ }
48
+ if (!required.has(name)) field = field.optional();
49
+ if (schema.description) field = field.describe(schema.description);
50
+ shape[name] = field;
51
+ }
52
+
53
+ return shape;
54
+ }
55
+
29
56
  export function registerMcpTools(server, {
30
57
  readSessionStateFn = readSessionState,
31
58
  createTransportFn = createTransport
@@ -68,6 +95,41 @@ export function registerMcpTools(server, {
68
95
  });
69
96
  }
70
97
 
98
+ for (const tool of listCoachReadTools()) {
99
+ server.tool(tool.name, tool.description, coachToolShape(tool), async (args) => {
100
+ try {
101
+ const sessionState = await readSessionStateFn();
102
+ const transport = await createTransportFn({}, sessionState);
103
+
104
+ if (transport.expired) {
105
+ return {
106
+ content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
107
+ isError: true
108
+ };
109
+ }
110
+
111
+ const result = await transport.executeCoachReadTool(tool.name, args);
112
+ return {
113
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
114
+ };
115
+ } catch (error) {
116
+ const message = error && error.message ? error.message : String(error);
117
+
118
+ if (error && error.code === 'SNAPSHOT_NOT_FOUND') {
119
+ return {
120
+ content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
121
+ isError: true
122
+ };
123
+ }
124
+
125
+ return {
126
+ content: [{ type: 'text', text: message }],
127
+ isError: true
128
+ };
129
+ }
130
+ });
131
+ }
132
+
71
133
  return server;
72
134
  }
73
135
 
@@ -88,6 +150,11 @@ export function createSandboxServer() {
88
150
  sandbox: true,
89
151
  ok: true
90
152
  }),
153
+ executeCoachReadTool: async (toolName) => ({
154
+ toolName,
155
+ sandbox: true,
156
+ ok: true
157
+ }),
91
158
  executeWriteCommand: async (commandId) => ({
92
159
  commandId,
93
160
  sandbox: true,
package/src/openrouter.js CHANGED
@@ -24,7 +24,7 @@ const TRACE_DETAIL_METADATA = 'metadata';
24
24
  const TRACE_DETAIL_RAW_INTERNAL = 'raw_internal';
25
25
 
26
26
  export const AI_PROMPT_VERSIONS = Object.freeze({
27
- workout: 'workout_v2026_04_24_2',
27
+ workout: 'workout_v2026_05_06_1',
28
28
  cycle: 'cycle_v2026_04_18_1',
29
29
  vitals: 'vitals_v2026_04_16_1',
30
30
  checkpoint: 'checkpoint_v2026_04_16_1',
@@ -892,7 +892,7 @@ The app already shows PRs, total volume, effort score, exercise breakdown, and p
892
892
  Rules:
893
893
  - No bullet points, no questions.
894
894
  - Be specific — use exact exercise names from the session data. Do not shorten or generalize.
895
- - Only mention exercises that appear in the current session, the next session list, or the recorded PR list. Never reference skipped or absent exercises by name.
895
+ - Only mention exercises that appear in the current session, the next session list, the recorded PR list, or the plan comparison block. You may name a skipped exercise from plan comparison if it adds insight (e.g. context for the day's shape), but at most one such mention, and never speculate on why it was skipped unless the context states a reason.
896
896
  - Do not summarize PRs with a count in workout notes. Name the specific lift or lifts instead.
897
897
  - Never use the phrase "rep PR" in a workout note.
898
898
  - Do not state a percentage change unless the exact percentage is directly supported by the comparison block.
package/src/queries.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { coachFactPolicyViolation } from './coach-facts.js';
2
2
  import { exerciseAliasMapping } from './exercise-aliases.js';
3
3
  import { programPhaseWindowContext, resolveProgramPhase } from './program-phase-resolver.js';
4
+ import { enrichScoreSnapshots } from './score-context.js';
4
5
 
5
6
  function completionDateForSession(session) {
6
7
  return session.completedAt ?? session.summary?.date ?? session.date;
@@ -2883,53 +2884,150 @@ export function getRecords(snapshot, { exercises = [], limit = 15 } = {}) {
2883
2884
  });
2884
2885
  }
2885
2886
 
2886
- export function getIncrementScore(snapshot, { historyDays = 14 } = {}) {
2887
- const raw = snapshot?.incrementScore;
2887
+ function scoreComponentNumber(value) {
2888
+ const num = typeof value === 'number' ? value : value?.score;
2889
+ return typeof num === 'number' && Number.isFinite(num) ? num : null;
2890
+ }
2891
+
2892
+ function scoreDriverLabels(list, limit = 5) {
2893
+ if (!Array.isArray(list)) return [];
2894
+ return list.slice(0, limit).map((d) => d?.label ?? d?.message ?? d?.id ?? d?.driver).filter(Boolean);
2895
+ }
2896
+
2897
+ function normalizeScoreHistory(raw) {
2888
2898
  const history = Array.isArray(raw?.history) ? raw.history : Array.isArray(raw) ? raw : [];
2889
2899
  const latest = raw?.latest ?? history[0] ?? null;
2900
+ const first = history[0] ?? null;
2901
+ const sameFirst = latest && first && (
2902
+ (latest.snapshotAt && first.snapshotAt && latest.snapshotAt === first.snapshotAt) ||
2903
+ (latest === first)
2904
+ );
2905
+ const mergedHistory = latest && sameFirst
2906
+ ? [{ ...first, ...latest }, ...history.slice(1)]
2907
+ : latest
2908
+ ? [latest, ...history]
2909
+ : history;
2910
+ return enrichScoreSnapshots(mergedHistory);
2911
+ }
2912
+
2913
+ export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
2914
+ const raw = snapshot?.incrementScore;
2915
+ const history = normalizeScoreHistory(raw);
2916
+ const latest = history[0] ?? null;
2917
+ const boundedHistoryDays = boundedInteger(historyDays, { defaultValue: 14, min: 1, max: 60 });
2890
2918
 
2891
2919
  if (!latest || typeof latest.score !== 'number') {
2892
- return coachToolResult('get_increment_score', { historyDays }, {
2893
- facts: {},
2920
+ return {
2921
+ available: false,
2922
+ score: null,
2923
+ snapshotAt: null,
2924
+ formulaVersion: null,
2925
+ dataTier: null,
2926
+ components: {},
2927
+ topPositiveDrivers: [],
2928
+ topNegativeDrivers: [],
2929
+ dayOverDayDelta: null,
2930
+ recentTrend: [],
2931
+ dataQualityNotes: ['No Increment Score snapshots found.'],
2894
2932
  missingDataFlags: ['no_increment_score']
2895
- });
2933
+ };
2896
2934
  }
2897
2935
 
2898
2936
  const components = {};
2899
2937
  if (latest.components && typeof latest.components === 'object') {
2900
2938
  for (const [name, value] of Object.entries(latest.components)) {
2901
- const num = typeof value === 'number' ? value : value?.score;
2902
- if (typeof num === 'number') components[name] = num;
2939
+ const num = scoreComponentNumber(value);
2940
+ if (num != null) components[name] = num;
2903
2941
  }
2904
2942
  }
2905
2943
 
2906
- const trimmedHistory = history.slice(0, Math.min(Math.max(Number(historyDays) || 14, 1), 60));
2907
- const recentScores = trimmedHistory
2908
- .map((entry) => (typeof entry?.score === 'number' ? entry.score : null))
2909
- .filter((s) => s != null);
2910
-
2944
+ const trimmedHistory = history.slice(0, boundedHistoryDays);
2911
2945
  const prior = trimmedHistory[1];
2912
2946
  const dayOverDayDelta = (typeof prior?.score === 'number')
2913
2947
  ? latest.score - prior.score
2914
2948
  : null;
2915
2949
 
2916
- const driverLabels = (list) => {
2917
- if (!Array.isArray(list)) return [];
2918
- return list.slice(0, 5).map((d) => d?.label ?? d?.id ?? d?.driver).filter(Boolean);
2950
+ const missingDataFlags = [];
2951
+ const dataQualityNotes = [];
2952
+ if (Object.keys(components).length === 0) {
2953
+ missingDataFlags.push('no_components');
2954
+ dataQualityNotes.push('Component scores are missing for this snapshot.');
2955
+ }
2956
+ if (!latest.dataTier) {
2957
+ missingDataFlags.push('no_data_tier');
2958
+ dataQualityNotes.push('Data tier is missing for this snapshot.');
2959
+ }
2960
+ if (!latest.formulaVersion) {
2961
+ missingDataFlags.push('no_formula_version');
2962
+ dataQualityNotes.push('Formula version is missing for this snapshot.');
2963
+ }
2964
+
2965
+ const recentTrend = trimmedHistory
2966
+ .filter((entry) => typeof entry?.score === 'number')
2967
+ .map((entry) => ({
2968
+ snapshotAt: entry.snapshotAt ?? null,
2969
+ score: entry.score,
2970
+ dataTier: entry.dataTier ?? null,
2971
+ formulaVersion: entry.formulaVersion ?? null
2972
+ }));
2973
+
2974
+ return {
2975
+ available: true,
2976
+ score: latest.score,
2977
+ snapshotAt: latest.snapshotAt ?? null,
2978
+ formulaVersion: latest.formulaVersion ?? null,
2979
+ dataTier: latest.dataTier ?? null,
2980
+ components,
2981
+ topPositiveDrivers: scoreDriverLabels(latest.topPositiveDrivers),
2982
+ topNegativeDrivers: scoreDriverLabels(latest.topNegativeDrivers),
2983
+ dayOverDayDelta,
2984
+ recentTrend,
2985
+ dataQualityNotes,
2986
+ missingDataFlags,
2987
+ scoreBand: latest.scoreBand ?? null,
2988
+ summaryText: latest.summaryText ?? null
2919
2989
  };
2990
+ }
2991
+
2992
+ export function incrementScoreCurrent(snapshot, options = {}) {
2993
+ return incrementScoreSummary(snapshot, options);
2994
+ }
2995
+
2996
+ export function incrementScoreHistory(snapshot, options = {}) {
2997
+ const raw = snapshot?.incrementScore;
2998
+ const history = normalizeScoreHistory(raw);
2999
+ const limit = boundedInteger(options.limit, { defaultValue: 200, min: 1, max: 1000 });
3000
+ const from = options.from ? new Date(options.from) : null;
3001
+ const to = options.to ? new Date(options.to) : null;
3002
+ const filtered = history.filter((entry) => {
3003
+ if (!entry?.snapshotAt) return true;
3004
+ const date = new Date(entry.snapshotAt);
3005
+ if (Number.isNaN(date.getTime())) return true;
3006
+ if (from && !Number.isNaN(from.getTime()) && date < from) return false;
3007
+ if (to && !Number.isNaN(to.getTime()) && date > to) return false;
3008
+ return true;
3009
+ });
3010
+
3011
+ return { snapshots: filtered.slice(0, limit) };
3012
+ }
3013
+
3014
+ export function getIncrementScore(snapshot, { historyDays = 14 } = {}) {
3015
+ const summary = incrementScoreSummary(snapshot, { historyDays });
3016
+
3017
+ if (!summary.available) {
3018
+ return coachToolResult('get_increment_score', { historyDays }, {
3019
+ facts: {},
3020
+ missingDataFlags: summary.missingDataFlags
3021
+ });
3022
+ }
2920
3023
 
2921
3024
  return coachToolResult('get_increment_score', { historyDays }, {
2922
3025
  facts: {
2923
- score: latest.score,
2924
- dataTier: latest.dataTier ?? null,
2925
- components,
2926
- topPositiveDrivers: driverLabels(latest.topPositiveDrivers),
2927
- topNegativeDrivers: driverLabels(latest.topNegativeDrivers),
2928
- dayOverDayDelta,
2929
- recentScores
3026
+ ...summary,
3027
+ recentScores: summary.recentTrend.map((entry) => entry.score)
2930
3028
  },
2931
- sourceTimestamp: latest.snapshotAt ?? null,
2932
- missingDataFlags: Object.keys(components).length === 0 ? ['no_components'] : []
3029
+ sourceTimestamp: summary.snapshotAt,
3030
+ missingDataFlags: summary.missingDataFlags
2933
3031
  });
2934
3032
  }
2935
3033
 
@@ -4308,6 +4406,14 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
4308
4406
  return { ok: true, payload: trainingLoad(snapshot) };
4309
4407
  }
4310
4408
 
4409
+ if (normalizedCommand === 'increment-score-current') {
4410
+ return { ok: true, payload: incrementScoreCurrent(snapshot, options) };
4411
+ }
4412
+
4413
+ if (normalizedCommand === 'increment-score-history') {
4414
+ return { ok: true, payload: incrementScoreHistory(snapshot, options) };
4415
+ }
4416
+
4311
4417
  if (normalizedCommand === 'ask-history') {
4312
4418
  const conversations = Array.isArray(snapshot.askConversations)
4313
4419
  ? snapshot.askConversations
package/src/remote.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { readSnapshot } from './local.js';
3
- import { executeReadCommand } from './queries.js';
3
+ import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand } from './queries.js';
4
4
  import { resolveServiceUrl } from './service-url.js';
5
5
 
6
6
  function notImplementedError() {
@@ -41,6 +41,7 @@ const remoteCommandHandlers = {
41
41
  'ask-history': executeRemoteRead,
42
42
  'ask-show': executeRemoteRead,
43
43
  'program-share-fetch': executeRemoteRead,
44
+ 'increment-score-current': executeRemoteRead,
44
45
  'increment-score-history': executeRemoteRead
45
46
  };
46
47
 
@@ -162,6 +163,11 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
162
163
  return resolveServiceUrl(baseUrl, `/cli/ask/history/${options.id}`);
163
164
  case 'program-share-fetch':
164
165
  return resolveServiceUrl(baseUrl, `/program-share/${options.token}`);
166
+ case 'increment-score-current': {
167
+ const url = resolveServiceUrl(baseUrl, '/cli/increment-score/current');
168
+ if (options.historyDays) url.searchParams.set('historyDays', options.historyDays);
169
+ return url;
170
+ }
165
171
  case 'increment-score-history': {
166
172
  const url = resolveServiceUrl(baseUrl, '/mobile/score-snapshots');
167
173
  if (options.from) url.searchParams.set('from', options.from);
@@ -198,6 +204,40 @@ function resourceNotFoundMessage(normalizedCommand, options) {
198
204
  return 'Requested resource was not found.';
199
205
  }
200
206
 
207
+ async function executeRemoteCoachReadTool(toolName, input, sessionState) {
208
+ const baseUrl = sessionState.session?.transport?.baseUrl;
209
+ if (baseUrl) {
210
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-tools/${encodeURIComponent(toolName)}`);
211
+ const response = await fetch(endpoint, {
212
+ method: 'POST',
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
216
+ },
217
+ body: JSON.stringify(input ?? {})
218
+ });
219
+
220
+ if (response.status === 401 || response.status === 403) throw authenticationFailedError();
221
+ if (response.status === 404) {
222
+ const error = new Error(`Unknown coach read tool: ${toolName}`);
223
+ error.code = 'REMOTE_NOT_FOUND';
224
+ throw error;
225
+ }
226
+ if (!response.ok) {
227
+ const payload = await response.json().catch(() => null);
228
+ const error = new Error(payload?.error ?? `Unexpected error from incremnt sync service (HTTP ${response.status}).`);
229
+ error.code = 'REMOTE_HTTP_ERROR';
230
+ throw error;
231
+ }
232
+ return response.json();
233
+ }
234
+
235
+ const fixturePath = sessionState.session?.transport?.fixturePath;
236
+ if (!fixturePath) throw notImplementedError();
237
+ const snapshot = await readSnapshot(fixturePath);
238
+ return executeLocalCoachReadTool(snapshot, toolName, input);
239
+ }
240
+
201
241
  const remoteWriteCommandHandlers = {
202
242
  'programs-propose': async (options, sessionState) => {
203
243
  const baseUrl = sessionState.session?.transport?.baseUrl;
@@ -453,6 +493,13 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
453
493
 
454
494
  return handler(options, sessionState, normalizedCommand);
455
495
  },
496
+ async executeCoachReadTool(toolName, input = {}) {
497
+ if (transportOptions.expired) {
498
+ throw expiredSessionError();
499
+ }
500
+
501
+ return executeRemoteCoachReadTool(toolName, input, sessionState);
502
+ },
456
503
  async executeWriteCommand(normalizedCommand, options = {}) {
457
504
  if (transportOptions.expired) {
458
505
  throw expiredSessionError();
@@ -0,0 +1,182 @@
1
+ // Derived context for INCREMNT Score snapshots.
2
+ //
3
+ // These fields are computed at response time from existing snapshot data.
4
+ // They are NOT persisted — pure projections of (current snapshot, previous
5
+ // snapshot) into agent-friendly explanatory shape. See GitHub issue #498.
6
+
7
+ // Score bands. Inclusive lower bound, exclusive upper bound (except 'peak').
8
+ // 0..40 weak
9
+ // 40..60 developing
10
+ // 60..75 solid
11
+ // 75..90 strong
12
+ // 90..100 peak
13
+ export const SCORE_BANDS = [
14
+ { name: 'weak', min: 0, max: 40 },
15
+ { name: 'developing', min: 40, max: 60 },
16
+ { name: 'solid', min: 60, max: 75 },
17
+ { name: 'strong', min: 75, max: 90 },
18
+ { name: 'peak', min: 90, max: 101 }
19
+ ];
20
+
21
+ export function computeScoreBand(score) {
22
+ if (typeof score !== 'number' || !Number.isFinite(score)) return null;
23
+ for (const band of SCORE_BANDS) {
24
+ if (score >= band.min && score < band.max) return band.name;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Component-keyed action templates surfaced as `recommendedNextActions` for
30
+ // each top-2 negative driver. Keep these short, imperative, single-line.
31
+ const COMPONENT_ACTIONS = {
32
+ coverage: 'Add the missing muscle groups to your next session to close coverage gaps.',
33
+ recovery: 'Prioritise sleep and an easier session to let recovery rebound.',
34
+ stimulus: 'Push closer to productive weekly volume on your lagging muscle groups.',
35
+ execution: 'Hit your planned sets and reps with cleaner technique next session.',
36
+ progression: 'Add a small load or rep increase on your main lifts next session.'
37
+ };
38
+
39
+ const GENERIC_ACTION = 'Address this driver in your next session to lift the score.';
40
+
41
+ function actionForDriver(driver) {
42
+ if (!driver || typeof driver !== 'object') return GENERIC_ACTION;
43
+ const component = typeof driver.component === 'string' ? driver.component : null;
44
+ if (component && COMPONENT_ACTIONS[component]) {
45
+ return COMPONENT_ACTIONS[component];
46
+ }
47
+ return GENERIC_ACTION;
48
+ }
49
+
50
+ function driverDisplayMessage(driver) {
51
+ if (!driver || typeof driver !== 'object') return null;
52
+ if (typeof driver.message === 'string' && driver.message.trim()) return driver.message;
53
+ if (typeof driver.label === 'string' && driver.label.trim()) return driver.label;
54
+ return null;
55
+ }
56
+
57
+ export function computeRecommendedNextActions(topNegativeDrivers) {
58
+ if (!Array.isArray(topNegativeDrivers) || topNegativeDrivers.length === 0) return [];
59
+ return topNegativeDrivers.slice(0, 2).map((driver) => ({
60
+ component: typeof driver?.component === 'string' ? driver.component : null,
61
+ driverMessage: driverDisplayMessage(driver),
62
+ action: actionForDriver(driver)
63
+ }));
64
+ }
65
+
66
+ // Top-2 component movers (by absolute change) between current and previous
67
+ // snapshot. Keys present in either side are considered; missing values
68
+ // treated as 0.
69
+ export function computeDeltaDrivers(currentComponents, previousComponents) {
70
+ if (
71
+ !currentComponents || typeof currentComponents !== 'object' ||
72
+ !previousComponents || typeof previousComponents !== 'object'
73
+ ) {
74
+ return [];
75
+ }
76
+ const keys = new Set([
77
+ ...Object.keys(currentComponents),
78
+ ...Object.keys(previousComponents)
79
+ ]);
80
+ const moves = [];
81
+ for (const key of keys) {
82
+ const cur = Number(currentComponents[key]);
83
+ const prev = Number(previousComponents[key]);
84
+ if (!Number.isFinite(cur) && !Number.isFinite(prev)) continue;
85
+ const c = Number.isFinite(cur) ? cur : 0;
86
+ const p = Number.isFinite(prev) ? prev : 0;
87
+ const delta = c - p;
88
+ if (delta === 0) continue;
89
+ moves.push({
90
+ component: key,
91
+ previousValue: p,
92
+ currentValue: c,
93
+ delta: Number(delta.toFixed(2))
94
+ });
95
+ }
96
+ moves.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
97
+ return moves.slice(0, 2);
98
+ }
99
+
100
+ function formatDelta(delta) {
101
+ if (delta === null || delta === undefined) return null;
102
+ if (delta === 0) return 'flat';
103
+ return delta > 0 ? `up ${delta}` : `down ${Math.abs(delta)}`;
104
+ }
105
+
106
+ export function computeSummaryText(enriched) {
107
+ if (!enriched || typeof enriched.score !== 'number') return null;
108
+ const parts = [];
109
+ const bandLabel = enriched.scoreBand ? ` (${enriched.scoreBand})` : '';
110
+ parts.push(`INCREMNT Score ${enriched.score}${bandLabel}.`);
111
+
112
+ if (typeof enriched.previousScore === 'number') {
113
+ const trend = formatDelta(enriched.delta);
114
+ if (enriched.comparisonSafe) {
115
+ parts.push(`${trend === 'flat' ? 'Essentially flat' : `Trend ${trend}`} vs previous ${enriched.previousScore}.`);
116
+ } else {
117
+ parts.push(`Previous ${enriched.previousScore} (formula version differs or is unavailable, comparison unsafe).`);
118
+ }
119
+ } else {
120
+ parts.push('No prior snapshot for comparison.');
121
+ }
122
+
123
+ const topNeg = Array.isArray(enriched.topNegativeDrivers) ? enriched.topNegativeDrivers[0] : null;
124
+ const topNegMessage = driverDisplayMessage(topNeg);
125
+ if (topNegMessage) {
126
+ parts.push(`Top drag: ${topNegMessage}.`);
127
+ }
128
+
129
+ const firstAction = Array.isArray(enriched.recommendedNextActions) ? enriched.recommendedNextActions[0] : null;
130
+ if (firstAction && typeof firstAction.action === 'string') {
131
+ parts.push(`Next: ${firstAction.action}`);
132
+ }
133
+
134
+ return parts.join(' ');
135
+ }
136
+
137
+ // Enrich an array of snapshots (newest first, as returned by listScoreSnapshots).
138
+ // Adds derived fields to each snapshot in-place via a shallow copy. Existing
139
+ // fields are preserved; only new fields are added.
140
+ export function enrichScoreSnapshots(snapshots) {
141
+ if (!Array.isArray(snapshots) || snapshots.length === 0) return [];
142
+ return snapshots.map((snapshot, index) => {
143
+ const previous = snapshots[index + 1] ?? null;
144
+ return enrichScoreSnapshot(snapshot, previous);
145
+ });
146
+ }
147
+
148
+ export function enrichScoreSnapshot(current, previous) {
149
+ if (!current || typeof current !== 'object') return current;
150
+
151
+ const previousScore = previous && typeof previous.score === 'number' ? previous.score : null;
152
+ const delta = previousScore !== null && typeof current.score === 'number'
153
+ ? current.score - previousScore
154
+ : null;
155
+
156
+ const comparisonSafe = !!(
157
+ previous &&
158
+ typeof previous.score === 'number' &&
159
+ typeof current.formulaVersion === 'string' &&
160
+ typeof previous.formulaVersion === 'string' &&
161
+ current.formulaVersion === previous.formulaVersion
162
+ );
163
+
164
+ const deltaDrivers = comparisonSafe
165
+ ? computeDeltaDrivers(current.components, previous.components)
166
+ : [];
167
+
168
+ const scoreBand = computeScoreBand(current.score);
169
+ const recommendedNextActions = computeRecommendedNextActions(current.topNegativeDrivers);
170
+
171
+ const enriched = {
172
+ ...current,
173
+ previousScore,
174
+ delta,
175
+ comparisonSafe,
176
+ deltaDrivers,
177
+ scoreBand,
178
+ recommendedNextActions
179
+ };
180
+ enriched.summaryText = computeSummaryText(enriched);
181
+ return enriched;
182
+ }
@@ -18,6 +18,7 @@ import {
18
18
  generateVitalsSummary,
19
19
  generateWorkoutCoachingSummary
20
20
  } from './openrouter.js';
21
+ import { computeScoreBand } from './score-context.js';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = path.dirname(__filename);
@@ -94,6 +95,8 @@ export function buildSummaryEvalContext(snapshot, testCase) {
94
95
  routedMetadata: routed?.metadata ?? null
95
96
  };
96
97
  }
98
+ case 'scoreCommentary':
99
+ return buildScoreCommentaryContext(snapshot, testCase);
97
100
  default:
98
101
  throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
99
102
  }
@@ -121,6 +124,8 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
121
124
 
122
125
  let result;
123
126
  switch (testCase.surface) {
127
+ case 'scoreCommentary':
128
+ return { output: testCase.output, metadata: {} };
124
129
  case 'workout':
125
130
  result = await generateWorkoutCoachingSummary(context, { apiKey });
126
131
  break;
@@ -166,6 +171,77 @@ function normalizeText(value) {
166
171
  return String(value ?? '').trim();
167
172
  }
168
173
 
174
+ function parseJsonOutput(output) {
175
+ const normalized = normalizeText(output);
176
+ if (!normalized) return null;
177
+ try {
178
+ return JSON.parse(normalized);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function scoreCommentaryPayload(output) {
185
+ const parsed = parseJsonOutput(output);
186
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
187
+ return parsed;
188
+ }
189
+ return { summaryText: normalizeText(output) };
190
+ }
191
+
192
+ function scoreCommentaryText(output) {
193
+ const payload = scoreCommentaryPayload(output);
194
+ const pieces = [
195
+ payload.headline,
196
+ payload.subhead,
197
+ payload.summaryText,
198
+ payload.scoreBand
199
+ ];
200
+ const actions = Array.isArray(payload.recommendedNextActions) ? payload.recommendedNextActions : [];
201
+ for (const action of actions) {
202
+ pieces.push(action?.driverMessage, action?.action);
203
+ }
204
+ const deltaDrivers = Array.isArray(payload.deltaDrivers) ? payload.deltaDrivers : [];
205
+ for (const driver of deltaDrivers) {
206
+ pieces.push(driver?.component);
207
+ }
208
+ return pieces.filter(Boolean).join(' ');
209
+ }
210
+
211
+ function scoreHistoryFromSnapshot(snapshot) {
212
+ const raw = snapshot?.incrementScore;
213
+ if (Array.isArray(raw)) return raw;
214
+
215
+ const history = Array.isArray(raw?.history) ? raw.history : [];
216
+ const latest = raw?.latest;
217
+ if (!latest) return history;
218
+
219
+ return [
220
+ latest,
221
+ ...history.filter((entry) => {
222
+ if (!entry) return false;
223
+ if (latest?.id != null && entry?.id != null) return entry.id !== latest.id;
224
+ if (latest?.snapshotAt != null && entry?.snapshotAt != null) {
225
+ return entry.snapshotAt !== latest.snapshotAt;
226
+ }
227
+ return entry !== latest;
228
+ })
229
+ ];
230
+ }
231
+
232
+ function buildScoreCommentaryContext(snapshot, testCase) {
233
+ const history = scoreHistoryFromSnapshot(snapshot);
234
+ const selector = testCase.selector ?? {};
235
+ if (selector.snapshotId) {
236
+ return history.find((entry) => entry.id === selector.snapshotId) ?? null;
237
+ }
238
+ if (selector.snapshotAt) {
239
+ return history.find((entry) => entry.snapshotAt === selector.snapshotAt) ?? null;
240
+ }
241
+ const index = Number.isInteger(selector.index) ? selector.index : 0;
242
+ return history[index] ?? null;
243
+ }
244
+
169
245
  function paragraphCount(text) {
170
246
  return normalizeText(text)
171
247
  .split(/\n\s*\n/)
@@ -279,6 +355,9 @@ function collectAllowedExerciseNames(surface, context) {
279
355
  for (const pr of context.repPrs ?? []) {
280
356
  if (pr.exerciseName) names.add(pr.exerciseName);
281
357
  }
358
+ for (const sc of context.planComparison?.setsComparison ?? []) {
359
+ if (sc.exercise) names.add(sc.exercise);
360
+ }
282
361
  }
283
362
 
284
363
  if (surface === 'cycle' && context && typeof context === 'object') {
@@ -307,6 +386,16 @@ function collectAllowedExerciseNames(surface, context) {
307
386
  }
308
387
  }
309
388
 
389
+ if (surface === 'scoreCommentary' && context && typeof context === 'object') {
390
+ for (const driver of [
391
+ ...(context.topPositiveDrivers ?? []),
392
+ ...(context.topNegativeDrivers ?? [])
393
+ ]) {
394
+ if (driver?.relatedExerciseName) names.add(driver.relatedExerciseName);
395
+ if (driver?.exerciseName) names.add(driver.exerciseName);
396
+ }
397
+ }
398
+
310
399
  return [...names];
311
400
  }
312
401
 
@@ -340,6 +429,7 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
340
429
  };
341
430
  }
342
431
 
432
+ const outputText = surface === 'scoreCommentary' ? scoreCommentaryText(output) : output;
343
433
  const isStored = testCase.source === 'stored';
344
434
  const allowed = new Set();
345
435
  for (const name of [
@@ -352,7 +442,7 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
352
442
  }
353
443
  }
354
444
  const allNames = collectAllExerciseNames(snapshot);
355
- const normalizedOutput = normalizeExerciseName(output);
445
+ const normalizedOutput = normalizeExerciseName(outputText);
356
446
  const mentions = [];
357
447
 
358
448
  for (const exerciseName of allNames) {
@@ -453,7 +543,9 @@ function evaluateNoInsight(output, testCase) {
453
543
  }
454
544
 
455
545
  function evaluateShape(output, testCase) {
456
- const normalized = normalizeText(output);
546
+ const normalized = testCase.surface === 'scoreCommentary'
547
+ ? scoreCommentaryText(output)
548
+ : normalizeText(output);
457
549
  if (normalized === 'NO_INSIGHT') {
458
550
  return {
459
551
  key: 'shape',
@@ -462,8 +554,8 @@ function evaluateShape(output, testCase) {
462
554
  };
463
555
  }
464
556
 
465
- const sentences = sentenceCount(output);
466
- const paragraphs = paragraphCount(output);
557
+ const sentences = sentenceCount(normalized);
558
+ const paragraphs = paragraphCount(normalized);
467
559
  const isStored = testCase.source === 'stored';
468
560
  let passed = true;
469
561
  const reasons = [];
@@ -527,6 +619,12 @@ function evaluateShape(output, testCase) {
527
619
  reasons.push(`Ask-coach answers must be 1-12 sentences, got ${sentences}.`);
528
620
  }
529
621
  break;
622
+ case 'scoreCommentary':
623
+ if (sentences < 1 || sentences > 8) {
624
+ passed = false;
625
+ reasons.push(`Score commentary must be 1-8 sentences, got ${sentences}.`);
626
+ }
627
+ break;
530
628
  default:
531
629
  break;
532
630
  }
@@ -1124,6 +1222,173 @@ function evaluateAskToolProvenance(output, context, testCase) {
1124
1222
  };
1125
1223
  }
1126
1224
 
1225
+ function firstAction(payload) {
1226
+ const actions = Array.isArray(payload?.recommendedNextActions) ? payload.recommendedNextActions : [];
1227
+ return actions.find((action) => typeof action?.action === 'string' && action.action.trim());
1228
+ }
1229
+
1230
+ function evaluateScoreCommentaryAction(output, context, testCase) {
1231
+ if (testCase.surface !== 'scoreCommentary') {
1232
+ return { key: 'score_commentary_action', passed: true, reason: 'Not score commentary.' };
1233
+ }
1234
+
1235
+ const payload = scoreCommentaryPayload(output);
1236
+ const hasNegativeDrivers = Array.isArray(context?.topNegativeDrivers) && context.topNegativeDrivers.length > 0;
1237
+ const hasAction = Boolean(firstAction(payload));
1238
+ const noTrainingSignal = context?.dataTier === 'noTrainingSignal';
1239
+ const failures = [];
1240
+
1241
+ if (noTrainingSignal && hasAction) {
1242
+ failures.push('No-training-signal score commentary must not include a recommended action.');
1243
+ }
1244
+ if (!noTrainingSignal && hasNegativeDrivers && !hasAction) {
1245
+ failures.push('Score commentary with negative drivers must include a recommended action.');
1246
+ }
1247
+
1248
+ return {
1249
+ key: 'score_commentary_action',
1250
+ passed: failures.length === 0,
1251
+ reason: failures.length === 0
1252
+ ? 'Score commentary action presence matches score state.'
1253
+ : failures.join(' ')
1254
+ };
1255
+ }
1256
+
1257
+ function driverKeyword(driver) {
1258
+ const text = normalizeText(driver?.message ?? driver?.label ?? driver?.driverMessage ?? driver?.component ?? '');
1259
+ const tokens = text
1260
+ .toLowerCase()
1261
+ .split(/[^a-z0-9]+/)
1262
+ .filter((token) => token.length >= 4 && !['score', 'sets', 'work', 'this', 'that', 'with', 'from', 'your'].includes(token));
1263
+ return tokens[0] ?? null;
1264
+ }
1265
+
1266
+ function evaluateScoreCommentarySynthesis(output, context, testCase) {
1267
+ if (testCase.surface !== 'scoreCommentary') {
1268
+ return { key: 'score_commentary_synthesis', passed: true, reason: 'Not score commentary.' };
1269
+ }
1270
+
1271
+ const positives = context?.topPositiveDrivers ?? [];
1272
+ const negatives = context?.topNegativeDrivers ?? [];
1273
+ if (positives.length === 0 || negatives.length === 0) {
1274
+ return {
1275
+ key: 'score_commentary_synthesis',
1276
+ passed: true,
1277
+ reason: 'Mixed-driver synthesis is not required for one-sided commentary.'
1278
+ };
1279
+ }
1280
+
1281
+ const text = scoreCommentaryText(output).toLowerCase();
1282
+ const positiveKeyword = driverKeyword(positives[0]);
1283
+ const negativeKeyword = driverKeyword(negatives[0]);
1284
+ const hasPositive = positiveKeyword ? text.includes(positiveKeyword) : true;
1285
+ const hasNegative = negativeKeyword ? text.includes(negativeKeyword) : true;
1286
+ const hasContrast = /\bbut\b|\bwhile\b|\balthough\b|\bhowever\b/.test(text);
1287
+
1288
+ return {
1289
+ key: 'score_commentary_synthesis',
1290
+ passed: hasPositive && hasNegative && hasContrast,
1291
+ reason: hasPositive && hasNegative && hasContrast
1292
+ ? 'Mixed positive and negative drivers are synthesized.'
1293
+ : 'Mixed-driver commentary must reference both directions with contrast language.'
1294
+ };
1295
+ }
1296
+
1297
+ function evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase) {
1298
+ if (testCase.surface !== 'scoreCommentary') {
1299
+ return { key: 'score_commentary_no_exercise_invention', passed: true, reason: 'Not score commentary.' };
1300
+ }
1301
+
1302
+ const allowedText = [
1303
+ ...(context?.topPositiveDrivers ?? []),
1304
+ ...(context?.topNegativeDrivers ?? [])
1305
+ ].map((driver) => [
1306
+ driver?.message,
1307
+ driver?.label,
1308
+ driver?.relatedExerciseName,
1309
+ driver?.relatedExerciseSlug,
1310
+ driver?.exerciseName
1311
+ ].filter(Boolean).join(' ')).join(' ');
1312
+ const normalizedAllowed = normalizeExerciseName(allowedText);
1313
+ const normalizedOutput = normalizeExerciseName(scoreCommentaryText(output));
1314
+ const invented = [];
1315
+
1316
+ for (const exerciseName of collectAllExerciseNames(snapshot)) {
1317
+ const normalizedName = normalizeExerciseName(exerciseName);
1318
+ if (!normalizedName) continue;
1319
+ const pattern = new RegExp(`(?<!\\S)${escapeRegex(normalizedName)}(?!\\S)`);
1320
+ if (pattern.test(normalizedOutput) && !pattern.test(normalizedAllowed)) {
1321
+ invented.push(exerciseName);
1322
+ }
1323
+ }
1324
+
1325
+ return {
1326
+ key: 'score_commentary_no_exercise_invention',
1327
+ passed: invented.length === 0,
1328
+ reason: invented.length === 0
1329
+ ? 'Score commentary only names exercises present in score drivers.'
1330
+ : `Invented exercise mention(s): ${uniqueStrings(invented).join(', ')}`
1331
+ };
1332
+ }
1333
+
1334
+ function evaluateScoreCommentaryBand(output, context, testCase) {
1335
+ if (testCase.surface !== 'scoreCommentary') {
1336
+ return { key: 'score_commentary_score_band', passed: true, reason: 'Not score commentary.' };
1337
+ }
1338
+
1339
+ const payload = scoreCommentaryPayload(output);
1340
+ const expected = computeScoreBand(context?.score);
1341
+ const actual = payload.scoreBand ?? null;
1342
+ return {
1343
+ key: 'score_commentary_score_band',
1344
+ passed: actual === expected,
1345
+ reason: actual === expected
1346
+ ? 'Score band matches canonical thresholds.'
1347
+ : `Expected scoreBand ${expected ?? 'null'}, got ${actual ?? 'null'}.`
1348
+ };
1349
+ }
1350
+
1351
+ function evaluateScoreCommentaryTone(output, testCase) {
1352
+ if (testCase.surface !== 'scoreCommentary') {
1353
+ return { key: 'score_commentary_tone', passed: true, reason: 'Not score commentary.' };
1354
+ }
1355
+
1356
+ const text = scoreCommentaryText(output);
1357
+ const failures = [];
1358
+ if (text.includes('!')) failures.push('exclamation mark');
1359
+ if (/\b(?:great job|awesome|crushing it|amazing work)\b/i.test(text)) failures.push('cheerleading phrase');
1360
+ if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) failures.push('emoji');
1361
+
1362
+ return {
1363
+ key: 'score_commentary_tone',
1364
+ passed: failures.length === 0,
1365
+ reason: failures.length === 0
1366
+ ? 'Score commentary tone is calm and non-cheerleading.'
1367
+ : `Tone violation(s): ${failures.join(', ')}.`
1368
+ };
1369
+ }
1370
+
1371
+ function evaluateScoreCommentaryLength(output, testCase) {
1372
+ if (testCase.surface !== 'scoreCommentary') {
1373
+ return { key: 'score_commentary_length', passed: true, reason: 'Not score commentary.' };
1374
+ }
1375
+
1376
+ const payload = scoreCommentaryPayload(output);
1377
+ const summary = normalizeText(payload.subhead ?? payload.summaryText);
1378
+ const action = normalizeText(firstAction(payload)?.action);
1379
+ const failures = [];
1380
+ if (summary.length > 140) failures.push(`summaryText/subhead is ${summary.length} chars, expected <= 140.`);
1381
+ if (action && action.length > 140) failures.push(`action is ${action.length} chars, expected <= 140.`);
1382
+
1383
+ return {
1384
+ key: 'score_commentary_length',
1385
+ passed: failures.length === 0,
1386
+ reason: failures.length === 0
1387
+ ? 'Score commentary text fits length bounds.'
1388
+ : failures.join(' ')
1389
+ };
1390
+ }
1391
+
1127
1392
  function personaEvalConfig(testCase) {
1128
1393
  if (testCase.personaEval === false) return null;
1129
1394
  if (testCase.personaEval && typeof testCase.personaEval === 'object') {
@@ -1234,6 +1499,12 @@ export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
1234
1499
  evaluateWorkoutClaims(output, context, testCase),
1235
1500
  evaluateAskClaims(output, snapshot, testCase),
1236
1501
  evaluateAskToolProvenance(output, context, testCase),
1502
+ evaluateScoreCommentaryAction(output, context, testCase),
1503
+ evaluateScoreCommentarySynthesis(output, context, testCase),
1504
+ evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase),
1505
+ evaluateScoreCommentaryBand(output, context, testCase),
1506
+ evaluateScoreCommentaryTone(output, testCase),
1507
+ evaluateScoreCommentaryLength(output, testCase),
1237
1508
  evaluatePersonaMotivation(output, context, testCase)
1238
1509
  ];
1239
1510
 
@@ -1269,6 +1540,8 @@ function genericForbiddenPhrasesForSurface(surface) {
1269
1540
  return [];
1270
1541
  case 'ask':
1271
1542
  return [];
1543
+ case 'scoreCommentary':
1544
+ return ['great job', 'awesome', 'crushing it', 'amazing work'];
1272
1545
  default:
1273
1546
  return [];
1274
1547
  }
@@ -3,6 +3,7 @@ import { anonymizeAccountId } from './anonymize.js';
3
3
  import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
4
4
  import { executeReadCommand } from './queries.js';
5
5
  import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
6
+ import { enrichScoreSnapshots } from './score-context.js';
6
7
 
7
8
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
8
9
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
@@ -783,6 +784,27 @@ function routeRequest(url, method) {
783
784
  };
784
785
  }
785
786
 
787
+ if (pathname === '/cli/increment-score/current') {
788
+ return {
789
+ command: 'increment-score-current',
790
+ options: {
791
+ historyDays: url.searchParams.get('historyDays') ?? undefined
792
+ }
793
+ };
794
+ }
795
+
796
+ {
797
+ const coachToolMatch = pathname.match(/^\/cli\/coach-tools\/([^/]+)$/);
798
+ if (coachToolMatch) {
799
+ return {
800
+ command: 'coach-tool',
801
+ options: {
802
+ toolName: decodeURIComponent(coachToolMatch[1])
803
+ }
804
+ };
805
+ }
806
+ }
807
+
786
808
  if (pathname === '/cli/account') {
787
809
  return { command: 'delete-account', options: {} };
788
810
  }
@@ -2879,7 +2901,8 @@ export function createSyncServiceRequestHandler({
2879
2901
  return;
2880
2902
  }
2881
2903
  try {
2882
- const snapshots = await listScoreSnapshotsForAccount(account, route.options ?? {});
2904
+ const rawSnapshots = await listScoreSnapshotsForAccount(account, route.options ?? {});
2905
+ const snapshots = enrichScoreSnapshots(rawSnapshots);
2883
2906
  json(response, 200, { snapshots });
2884
2907
  return;
2885
2908
  } catch (error) {
@@ -3476,6 +3499,58 @@ export function createSyncServiceRequestHandler({
3476
3499
  // Parse comma-separated exclude param into a Set for AI context builders
3477
3500
  const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
3478
3501
  const aiUser = anonymizeAccountId(account.id);
3502
+ const hydrateIncrementScore = async (limit = 14) => {
3503
+ if (!listScoreSnapshotsForAccount) return;
3504
+ const scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit }) ?? [];
3505
+ if (scoreSnapshots.length > 0) {
3506
+ const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
3507
+ snapshot.incrementScore = {
3508
+ latest: enrichedSnapshots[0],
3509
+ history: enrichedSnapshots
3510
+ };
3511
+ }
3512
+ };
3513
+
3514
+ if (route.command === 'increment-score-current') {
3515
+ if (request.method !== 'GET') {
3516
+ methodNotAllowed(response, 'Use GET for /cli/increment-score/current.');
3517
+ return;
3518
+ }
3519
+
3520
+ try {
3521
+ await hydrateIncrementScore(route.options.historyDays ?? 14);
3522
+ const queries = await import('./queries.js');
3523
+ json(response, 200, queries.incrementScoreCurrent(snapshot, route.options));
3524
+ return;
3525
+ } catch (error) {
3526
+ badRequest(response, error.message);
3527
+ return;
3528
+ }
3529
+ }
3530
+
3531
+ if (route.command === 'coach-tool') {
3532
+ if (request.method !== 'POST') {
3533
+ methodNotAllowed(response, 'Use POST for /cli/coach-tools/:toolName.');
3534
+ return;
3535
+ }
3536
+
3537
+ try {
3538
+ const body = await readJsonBody(request);
3539
+ const queries = await import('./queries.js');
3540
+ if (!queries.COACH_READ_TOOL_NAMES.includes(route.options.toolName)) {
3541
+ notFound(response, `Unknown coach read tool: ${route.options.toolName}`);
3542
+ return;
3543
+ }
3544
+ if (route.options.toolName === 'get_increment_score') {
3545
+ await hydrateIncrementScore(body?.historyDays ?? 14);
3546
+ }
3547
+ json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
3548
+ return;
3549
+ } catch (error) {
3550
+ badRequest(response, error.message);
3551
+ return;
3552
+ }
3553
+ }
3479
3554
 
3480
3555
  if (route.command === 'workout-summary-ai') {
3481
3556
  const sessionId = route.options['session-id'];
@@ -4074,9 +4149,10 @@ export function createSyncServiceRequestHandler({
4074
4149
  }
4075
4150
  }
4076
4151
  if (scoreSnapshots.length > 0) {
4152
+ const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
4077
4153
  snapshot.incrementScore = {
4078
- latest: scoreSnapshots[0],
4079
- history: scoreSnapshots
4154
+ latest: enrichedSnapshots[0],
4155
+ history: enrichedSnapshots
4080
4156
  };
4081
4157
  }
4082
4158
 
package/src/transport.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readSnapshot, resolveSnapshotSource } from './local.js';
2
2
  import { createRemoteTransport } from './remote.js';
3
- import { executeReadCommand } from './queries.js';
3
+ import { executeCoachReadTool, executeReadCommand } from './queries.js';
4
4
  import { isSessionExpired } from './state.js';
5
5
 
6
6
  function prefersLocal(options) {
@@ -33,6 +33,14 @@ function createLocalTransport(snapshotSource) {
33
33
 
34
34
  return result.payload;
35
35
  },
36
+ async executeCoachReadTool(toolName, input = {}) {
37
+ if (!snapshotSource) {
38
+ throw snapshotNotFoundError();
39
+ }
40
+
41
+ const snapshot = await readSnapshot(snapshotSource.path);
42
+ return executeCoachReadTool(snapshot, toolName, input);
43
+ },
36
44
  async executeWriteCommand() {
37
45
  const error = new Error('Write commands require a remote session. Run incremnt login first.');
38
46
  error.code = 'WRITE_REQUIRES_REMOTE';