incremnt 0.4.0 → 0.5.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,13 +67,20 @@ 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 history` | Historical Increment Score snapshots |
71
+ | `increment-score upload --file <file>` | Upload Increment Score snapshots |
70
72
  | `programs propose --file <file>` | Submit a program proposal |
71
73
  | `programs proposals` | List proposals |
72
74
  | `programs proposal dismiss --id <id>` | Dismiss a proposal |
75
+ | `programs share create --program-id <id>` | Create a public share token for a program |
76
+ | `programs share list --program-id <id>` | List share artifacts for a program |
77
+ | `programs share fetch --token <token>` | Fetch a publicly shared program |
78
+ | `programs share revoke --share-id <id>` | Revoke a previously issued share |
73
79
  | `browse` | Interactive Ink browser for sessions, programs, records, goals, cycles, and health |
74
80
  | `tui` | Alias for `browse` |
75
81
  | `mcp install` | Register `incremnt-mcp` with Claude Desktop, Claude Code, and Codex CLI |
76
- | `login` | Authenticate with the hosted sync service |
82
+ | `login` | Authenticate with the hosted sync service (opens browser) |
83
+ | `login --no-browser` | Headless login flow for SSH/Termius/remote shells |
77
84
  | `logout` | Clear stored session |
78
85
  | `status` | Show current mode, auth state, and config paths |
79
86
  | `contract` | Machine-readable command surface for scripts |
@@ -135,7 +142,7 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
135
142
 
136
143
  ### MCP tool surface
137
144
 
138
- 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, and program proposal workflows.
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`).
139
146
 
140
147
  You can inspect the exact machine-readable contract at any time:
141
148
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "engines": {
8
+ "node": ">=22.0.0"
9
+ },
7
10
  "bin": {
8
11
  "incremnt": "./src/index.js",
9
12
  "incremnt-mcp": "./src/mcp.js"
@@ -17,15 +20,28 @@
17
20
  "test:evals:real": "SUMMARY_EVAL_CASE_SET=anonymized-render node --test test/summary-evals.test.js",
18
21
  "evals:stored": "node ./scripts/run-summary-evals.js",
19
22
  "evals:stored:render": "node ./scripts/run-render-summary-evals.js",
23
+ "evals:promptfoo": "promptfoo eval -c promptfooconfig.yaml",
24
+ "evals:promptfoo:production-replay": "SUMMARY_EVAL_CASE_SET=production-replay promptfoo eval -c promptfooconfig.yaml",
25
+ "evals:promptfoo:live": "PROMPTFOO_LIVE=1 SUMMARY_EVALS_LIVE=1 promptfoo eval -c promptfooconfig.yaml",
20
26
  "evals:workout-bakeoff": "node ./scripts/run-workout-prompt-bakeoff.js",
27
+ "evals:coach-bakeoff": "node ./scripts/run-production-coach-bakeoff.js",
28
+ "evals:coach-replay": "node ./scripts/run-production-coach-replay.js",
29
+ "increment-score:replay": "node ./scripts/run-increment-score-replay.js",
30
+ "increment-score:replay-data": "node ./scripts/export-increment-score-replay-data.js",
31
+ "langfuse:smoke": "node ./scripts/smoke-langfuse-score.js",
21
32
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js",
22
33
  "postinstall": "node -e \"process.stdout.write('\\n incremnt MCP server installed.\\n Run: incremnt mcp install\\n to register it with Claude and Codex.\\n\\n')\""
23
34
  },
24
35
  "dependencies": {
36
+ "@langfuse/tracing": "^5.2.0",
25
37
  "@modelcontextprotocol/sdk": "^1.12.1",
26
38
  "chalk": "^5.6.2",
27
- "ink": "^5.2.1",
28
- "react": "^18.3.1",
29
- "zod": "^3.24.0"
39
+ "ink": "^7.0.1",
40
+ "openai": "^6.34.0",
41
+ "react": "^19.2.5",
42
+ "zod": "^4.3.6"
43
+ },
44
+ "devDependencies": {
45
+ "promptfoo": "^0.121.8"
30
46
  }
31
47
  }
@@ -0,0 +1,12 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function anonymizeAccountId(accountId) {
4
+ if (typeof accountId !== 'string' || !accountId.trim()) {
5
+ return 'anon:unknown';
6
+ }
7
+ const digest = createHash('sha256')
8
+ .update(`account:${accountId}`)
9
+ .digest('hex')
10
+ .slice(0, 12);
11
+ return `anon:${digest}`;
12
+ }
@@ -0,0 +1,300 @@
1
+ import { askContext, workoutSummaryContext } from './queries.js';
2
+ import { COACH_PROMPT_VARIANTS } from './coach-prompt-variants.js';
3
+
4
+ export const DEFAULT_COACH_BAKEOFF_MODELS = Object.freeze([
5
+ 'anthropic/claude-haiku-4.5',
6
+ 'openai/gpt-5.4-mini',
7
+ 'google/gemini-3-flash-preview',
8
+ 'z-ai/glm-5.1'
9
+ ]);
10
+
11
+ export const DEFAULT_COACH_REPLAY_CONFIGS = Object.freeze([
12
+ {
13
+ name: 'current-haiku',
14
+ label: 'Current Haiku',
15
+ model: 'anthropic/claude-haiku-4.5',
16
+ workoutPrompt: COACH_PROMPT_VARIANTS.current.workoutPrompt,
17
+ askPrompt: COACH_PROMPT_VARIANTS.current.askPrompt
18
+ },
19
+ {
20
+ name: 'revised-haiku',
21
+ label: 'Revised Haiku',
22
+ model: 'anthropic/claude-haiku-4.5',
23
+ workoutPrompt: COACH_PROMPT_VARIANTS.personality.workoutPrompt,
24
+ askPrompt: COACH_PROMPT_VARIANTS.warm.askPrompt
25
+ },
26
+ {
27
+ name: 'revised-mini',
28
+ label: 'Revised GPT-5.4 Mini',
29
+ model: 'openai/gpt-5.4-mini',
30
+ workoutPrompt: COACH_PROMPT_VARIANTS.personality.workoutPrompt,
31
+ askPrompt: COACH_PROMPT_VARIANTS.warm.askPrompt
32
+ }
33
+ ]);
34
+
35
+ function stableSortByDateDesc(items, selector) {
36
+ return [...items].sort((lhs, rhs) => String(selector(rhs)).localeCompare(String(selector(lhs))));
37
+ }
38
+
39
+ function completionDateForSession(session) {
40
+ return session?.completedAt
41
+ ?? session?.updatedAt
42
+ ?? session?.startTime
43
+ ?? session?.date
44
+ ?? '';
45
+ }
46
+
47
+ function buildStoredWorkoutEvalCase(session, snapshotLabel = 'snapshot') {
48
+ const anyOf = [
49
+ ...(session.exercises ?? []).map((exercise) => exercise.name),
50
+ session.dayName
51
+ ].filter(Boolean);
52
+
53
+ return {
54
+ id: `stored-workout-${session.id}`,
55
+ name: `Stored workout summary ${session.id}`,
56
+ surface: 'workout',
57
+ source: 'stored',
58
+ snapshotLabel,
59
+ selector: { sessionId: session.id },
60
+ output: session.summary?.aiCoachNotes ?? '',
61
+ metadata: null,
62
+ requiredMentions: [],
63
+ requiredAnyOfMentions: anyOf,
64
+ forbiddenPhrases: ['solid progress', 'trust the process', 'keep it up', 'quality work', 'in a great place', 'continue progressive overload', 'as fatigue accumulates'],
65
+ forbiddenMentions: [],
66
+ personaEval: { profile: 'motivated-athlete' },
67
+ expectNoInsight: false,
68
+ shouldPass: true
69
+ };
70
+ }
71
+
72
+ function buildStoredAskEvalCase(conversation, snapshotLabel = 'snapshot') {
73
+ return {
74
+ id: `stored-ask-${conversation.id}`,
75
+ name: `Stored ask conversation ${conversation.id}`,
76
+ surface: 'ask',
77
+ source: 'stored',
78
+ snapshotLabel,
79
+ context: { question: '', conversationId: conversation.id },
80
+ output: extractAskReplay(conversation)?.answer ?? '',
81
+ metadata: null,
82
+ requiredMentions: [],
83
+ requiredAnyOfMentions: [],
84
+ forbiddenPhrases: [],
85
+ forbiddenMentions: [],
86
+ personaEval: { profile: 'motivated-athlete' },
87
+ expectNoInsight: false,
88
+ shouldPass: true
89
+ };
90
+ }
91
+
92
+ export function findLatestStoredWorkoutSummarySession(snapshot) {
93
+ return stableSortByDateDesc(
94
+ (snapshot.sessions ?? []).filter((session) => session?.summary?.aiCoachNotes),
95
+ (session) => completionDateForSession(session)
96
+ )[0] ?? null;
97
+ }
98
+
99
+ export function extractAskReplay(conversation) {
100
+ if (!conversation || !Array.isArray(conversation.messages)) {
101
+ return null;
102
+ }
103
+
104
+ const assistantIndex = [...conversation.messages]
105
+ .map((message, index) => ({ message, index }))
106
+ .filter(({ message }) => message?.role === 'assistant' && typeof message?.content === 'string' && message.content.trim())
107
+ .at(-1)?.index;
108
+
109
+ if (assistantIndex == null) {
110
+ return null;
111
+ }
112
+
113
+ let questionIndex = -1;
114
+ for (let index = assistantIndex - 1; index >= 0; index -= 1) {
115
+ const message = conversation.messages[index];
116
+ if (message?.role === 'user' && typeof message?.content === 'string' && message.content.trim()) {
117
+ questionIndex = index;
118
+ break;
119
+ }
120
+ }
121
+
122
+ if (questionIndex === -1) {
123
+ return null;
124
+ }
125
+
126
+ const history = conversation.messages
127
+ .slice(0, questionIndex)
128
+ .filter((message) => ['user', 'assistant'].includes(message?.role))
129
+ .map((message) => ({ role: message.role, content: message.content }));
130
+
131
+ return {
132
+ question: conversation.messages[questionIndex].content,
133
+ history,
134
+ answer: conversation.messages[assistantIndex].content
135
+ };
136
+ }
137
+
138
+ export function findLatestAskConversation(snapshot) {
139
+ return stableSortByDateDesc(
140
+ (snapshot.askConversations ?? []).filter((conversation) => extractAskReplay(conversation)),
141
+ (conversation) => conversation?.createdAt ?? ''
142
+ )[0] ?? null;
143
+ }
144
+
145
+ export function buildCoachBakeoffTargets(snapshot, { sessionId, conversationId, exclude = new Set() } = {}) {
146
+ const selectedSession = sessionId
147
+ ? (snapshot.sessions ?? []).find((session) => session.id === sessionId) ?? null
148
+ : findLatestStoredWorkoutSummarySession(snapshot);
149
+ if (!selectedSession) {
150
+ throw new Error('No workout session with stored AI coach notes found.');
151
+ }
152
+
153
+ const selectedConversation = conversationId
154
+ ? (snapshot.askConversations ?? []).find((conversation) => conversation.id === conversationId) ?? null
155
+ : findLatestAskConversation(snapshot);
156
+ if (!selectedConversation) {
157
+ throw new Error('No ask conversation with a replayable user/assistant pair found.');
158
+ }
159
+
160
+ const askReplay = extractAskReplay(selectedConversation);
161
+ if (!askReplay) {
162
+ throw new Error(`Could not extract ask replay from conversation ${selectedConversation.id}.`);
163
+ }
164
+
165
+ const workout = {
166
+ sessionId: selectedSession.id,
167
+ dayName: selectedSession.dayName ?? 'Session',
168
+ completedAt: completionDateForSession(selectedSession),
169
+ storedOutput: selectedSession.summary?.aiCoachNotes ?? null,
170
+ context: workoutSummaryContext(snapshot, selectedSession.id, { exclude })
171
+ };
172
+
173
+ if (!workout.context) {
174
+ throw new Error(`Could not build workout context for session ${selectedSession.id}.`);
175
+ }
176
+
177
+ return {
178
+ workout,
179
+ ask: {
180
+ conversationId: selectedConversation.id,
181
+ createdAt: selectedConversation.createdAt ?? null,
182
+ question: askReplay.question,
183
+ history: askReplay.history,
184
+ storedOutput: askReplay.answer,
185
+ context: askContext(snapshot, { exclude })
186
+ }
187
+ };
188
+ }
189
+
190
+ export function harvestRecentCoachReplayCases(
191
+ snapshot,
192
+ { maxWorkoutCases = 5, maxAskCases = 5, exclude = new Set(), snapshotLabel = 'snapshot' } = {}
193
+ ) {
194
+ const askContextText = askContext(snapshot, { exclude });
195
+
196
+ const workoutCases = stableSortByDateDesc(
197
+ (snapshot.sessions ?? []).filter((session) => session?.summary?.aiCoachNotes),
198
+ (session) => completionDateForSession(session)
199
+ )
200
+ .slice(0, maxWorkoutCases)
201
+ .map((session) => ({
202
+ id: session.id,
203
+ surface: 'workout',
204
+ label: `${session.dayName ?? 'Session'} ${completionDateForSession(session)}`,
205
+ completedAt: completionDateForSession(session),
206
+ storedOutput: session.summary?.aiCoachNotes ?? '',
207
+ context: workoutSummaryContext(snapshot, session.id, { exclude }),
208
+ evalCase: buildStoredWorkoutEvalCase(session, snapshotLabel)
209
+ }))
210
+ .filter((entry) => entry.context);
211
+
212
+ const askCases = stableSortByDateDesc(
213
+ (snapshot.askConversations ?? []).filter((conversation) => extractAskReplay(conversation)),
214
+ (conversation) => conversation?.createdAt ?? ''
215
+ )
216
+ .slice(0, maxAskCases)
217
+ .map((conversation) => {
218
+ const replay = extractAskReplay(conversation);
219
+ return {
220
+ id: conversation.id,
221
+ surface: 'ask',
222
+ label: `Ask ${conversation.createdAt ?? conversation.id}`,
223
+ createdAt: conversation.createdAt ?? null,
224
+ question: replay.question,
225
+ history: replay.history,
226
+ storedOutput: replay.answer,
227
+ context: askContextText,
228
+ evalCase: buildStoredAskEvalCase(conversation, snapshotLabel)
229
+ };
230
+ });
231
+
232
+ return { workoutCases, askCases };
233
+ }
234
+
235
+ function formatGenerationLine(result) {
236
+ const bits = [result.model];
237
+ if (result.resolvedModel && result.resolvedModel !== result.model) {
238
+ bits.push(`resolved ${result.resolvedModel}`);
239
+ }
240
+ if (result.durationMs != null) {
241
+ bits.push(`${result.durationMs}ms`);
242
+ }
243
+ if (result.fallback) bits.push('fallback');
244
+ if (result.error) bits.push(`error: ${result.error}`);
245
+ return bits.join(' | ');
246
+ }
247
+
248
+ function pushOutputBlock(lines, heading, text, metaLine = null) {
249
+ lines.push(heading);
250
+ if (metaLine) lines.push(`- ${metaLine}`);
251
+ lines.push('');
252
+ lines.push(text.trim());
253
+ lines.push('');
254
+ }
255
+
256
+ export function formatCoachBakeoffMarkdownReport(report) {
257
+ const lines = [
258
+ '# Production Coach Bakeoff',
259
+ '',
260
+ `- Generated: ${report.generatedAt}`,
261
+ `- User: ${report.userId}`,
262
+ `- Workout session: ${report.targets.workout.sessionId} (${report.targets.workout.dayName}, ${report.targets.workout.completedAt})`,
263
+ `- Ask conversation: ${report.targets.ask.conversationId} (${report.targets.ask.createdAt})`,
264
+ `- Models: ${report.models.join(', ')}`,
265
+ `- Variants: ${report.variants.map((variant) => `${variant.name} (${variant.label})`).join(', ')}`,
266
+ ''
267
+ ];
268
+
269
+ lines.push('## Stored Baseline', '');
270
+ pushOutputBlock(lines, '### Workout', report.targets.workout.storedOutput ?? 'No stored workout summary.');
271
+ lines.push(`### Ask question`, '', report.targets.ask.question.trim(), '');
272
+ pushOutputBlock(lines, '### Ask stored answer', report.targets.ask.storedOutput ?? 'No stored ask answer.');
273
+
274
+ lines.push('## Context', '');
275
+ lines.push('### Workout context', '');
276
+ lines.push('```text');
277
+ lines.push(report.contextPreview.workout.trim());
278
+ lines.push('```', '');
279
+ lines.push('### Ask context', '');
280
+ lines.push('```text');
281
+ lines.push(report.contextPreview.ask.trim());
282
+ lines.push('```', '');
283
+
284
+ for (const surface of ['workout', 'ask']) {
285
+ lines.push(`## ${surface === 'workout' ? 'Workout summary bakeoff' : 'Ask follow-up bakeoff'}`, '');
286
+ for (const variant of report.variants) {
287
+ lines.push(`### ${variant.label}`, '');
288
+ for (const result of report.results.filter((entry) => entry.surface === surface && entry.variant === variant.name)) {
289
+ pushOutputBlock(
290
+ lines,
291
+ `#### ${result.model}`,
292
+ result.error ? `[generation failed]\n${result.error}` : result.output,
293
+ formatGenerationLine(result)
294
+ );
295
+ }
296
+ }
297
+ }
298
+
299
+ return `${lines.join('\n')}\n`;
300
+ }
@@ -0,0 +1,100 @@
1
+ export const COACH_FACT_KINDS = Object.freeze(['preference', 'constraint', 'injury', 'goal_signal', 'tone']);
2
+
3
+ const COACH_FACT_KIND_SET = new Set(COACH_FACT_KINDS);
4
+
5
+ const PROMPT_INJECTION_PATTERNS = [
6
+ /\b(ignore|disregard|override)\b.{0,40}\b(instructions?|rules?|system|developer|previous)\b/i,
7
+ /\b(system prompt|developer message|jailbreak|act as|you are chatgpt)\b/i
8
+ ];
9
+
10
+ const DERIVED_TRAINING_PATTERNS = [
11
+ /\b(e1rm|1rm|one[-\s]?rep max|estimated one[-\s]?rep max)\b/i,
12
+ /\b(personal record|rep pr|volume pr|\bpr\b)\b/i,
13
+ /\b(tonnage|training volume|total volume|session count|sets completed|missed sets)\b/i,
14
+ /\b\d+(?:\.\d+)?\s*(?:kg|kgs|kilograms?|lb|lbs|pounds?)\b.{0,40}\b(bench|squat|deadlift|press|curl|row|pulldown|leg|lift|rep|set|volume)\b/i,
15
+ /\b(bench|squat|deadlift|press|curl|row|pulldown|leg|lift)\b.{0,40}\b\d+(?:\.\d+)?\s*(?:kg|kgs|kilograms?|lb|lbs|pounds?)\b/i,
16
+ /\b\d+(?:\.\d+)?\s*x\s*\d+\b/i,
17
+ /\b(?:trained|completed|logged|did)\s+\d+\s+(?:sessions?|workouts?)\b/i
18
+ ];
19
+
20
+ const EPHEMERAL_PATTERNS = [
21
+ /\b(today|yesterday|tomorrow|this week|last week|next week|last session|latest session|recent session)\b/i,
22
+ /\b(current|currently|latest|recent)\s+(?:best|e1rm|1rm|volume|pr|record|session|workout)\b/i
23
+ ];
24
+
25
+ export function normalizeCoachFactText(value) {
26
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
27
+ }
28
+
29
+ export function normalizeCoachFactKind(value) {
30
+ return String(value ?? '').trim();
31
+ }
32
+
33
+ export function isCoachFactKind(value) {
34
+ return COACH_FACT_KIND_SET.has(normalizeCoachFactKind(value));
35
+ }
36
+
37
+ export function coachFactPolicyViolation(candidate) {
38
+ const kind = normalizeCoachFactKind(candidate?.kind);
39
+ const fact = normalizeCoachFactText(candidate?.fact);
40
+ if (!isCoachFactKind(kind)) return 'invalid_kind';
41
+ if (fact.length < 3) return 'too_short';
42
+ if (fact.length > 160) return 'too_long';
43
+ if (!/^the trainee\b/i.test(fact)) return 'not_third_person';
44
+ if (PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(fact))) return 'prompt_injection';
45
+ if (DERIVED_TRAINING_PATTERNS.some((pattern) => pattern.test(fact))) return 'derived_training_fact';
46
+ if (EPHEMERAL_PATTERNS.some((pattern) => pattern.test(fact))) return 'ephemeral_fact';
47
+ return null;
48
+ }
49
+
50
+ export function normalizeCoachFactCandidate(candidate) {
51
+ if (!candidate || typeof candidate !== 'object') return null;
52
+ const normalized = {
53
+ kind: normalizeCoachFactKind(candidate.kind),
54
+ fact: normalizeCoachFactText(candidate.fact),
55
+ confidence: Number(candidate.confidence ?? 0.7)
56
+ };
57
+ if (coachFactPolicyViolation(normalized)) return null;
58
+ return {
59
+ ...normalized,
60
+ confidence: Number.isFinite(normalized.confidence) ? Math.max(0, Math.min(1, normalized.confidence)) : 0.7
61
+ };
62
+ }
63
+
64
+ export function coachFactConflictKey(candidate) {
65
+ const kind = normalizeCoachFactKind(candidate?.kind);
66
+ const fact = normalizeCoachFactText(candidate?.fact).toLowerCase();
67
+ if (kind === 'tone' && /\b(concise|brief|short|terse|direct|succinct|detailed|thorough|long[-\s]?form|explain)\b/.test(fact)) {
68
+ return 'tone:verbosity';
69
+ }
70
+ return null;
71
+ }
72
+
73
+ export function coachFactConflictSearchPattern(candidate) {
74
+ const key = coachFactConflictKey(candidate);
75
+ if (key !== 'tone:verbosity') return null;
76
+ const fact = normalizeCoachFactText(candidate?.fact).toLowerCase();
77
+ if (/\b(concise|brief|short|terse|direct|succinct)\b/.test(fact)) {
78
+ return '\\m(detailed|thorough|long[-[:space:]]?form|explain)\\M';
79
+ }
80
+ if (/\b(detailed|thorough|long[-\s]?form|explain)\b/.test(fact)) {
81
+ return '\\m(concise|brief|short|terse|direct|succinct)\\M';
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export function dedupeCoachFactCandidates(candidates, { limit = 3 } = {}) {
87
+ const byKey = new Map();
88
+ for (const candidate of Array.isArray(candidates) ? candidates : []) {
89
+ const normalized = normalizeCoachFactCandidate(candidate);
90
+ if (!normalized) continue;
91
+ const key = coachFactConflictKey(normalized) ?? `${normalized.kind}:${normalized.fact.toLowerCase()}`;
92
+ const existing = byKey.get(key);
93
+ if (!existing || normalized.confidence >= existing.confidence) {
94
+ byKey.set(key, normalized);
95
+ }
96
+ }
97
+ return [...byKey.values()]
98
+ .sort((a, b) => b.confidence - a.confidence)
99
+ .slice(0, limit);
100
+ }
@@ -0,0 +1,106 @@
1
+ import { ASK_PROMPT, SECURITY_PREAMBLE, WORKOUT_COACH_PROMPT } from './openrouter.js';
2
+
3
+ export const WARM_WORKOUT_COACH_PROMPT = `${SECURITY_PREAMBLE}You are a training coach reviewing a completed session. Write a short post-workout note in 2-3 sentences, single paragraph.
4
+
5
+ Goal order:
6
+ 1. Leave the user feeling good about training.
7
+ 2. Surface one real signal from the log.
8
+ 3. Mention a miss lightly, only if it matters.
9
+
10
+ Style:
11
+ - Lead with a warm, grounded opener.
12
+ - Start with what went well or what the session covered before any watch item.
13
+ - One observation is enough. Use exact exercises and numbers.
14
+ - If you mention muscle coverage or session balance, anchor it to the logged exercises. Do not use generic filler.
15
+ - If the strongest watch item is minor, keep it brief and neutral.
16
+ - If the note would add nothing beyond the visible workout log, return exactly: NO_INSIGHT.
17
+
18
+ Rules:
19
+ - No bullet points, no questions.
20
+ - No audit language like "fell short of plan volume", "concern", "risk", "execution issue", or "red flag".
21
+ - Do not force a problem into every note.
22
+ - Do not infer fatigue, under-recovery, or cardio interference unless the context includes explicit recovery support.
23
+ - Use exact exercise names from the current session data.
24
+ - If you mention the next session, keep it short and optional. The next session title alone is enough.
25
+ - Never mention skipped or absent exercises.
26
+ - Never use hype language such as "crushed it", "insane", "elite", or "on fire".`;
27
+
28
+ export const PERSONALITY_WORKOUT_COACH_PROMPT = `${SECURITY_PREAMBLE}You are a training coach writing a short post-workout note with a bit of personality. Write 2-3 sentences, single paragraph.
29
+
30
+ Goal order:
31
+ 1. Make the user want to receive this note after training.
32
+ 2. Keep it grounded in the actual log.
33
+ 3. Add one light opinion or human-feeling observation if it is earned by the data.
34
+
35
+ Style:
36
+ - Sound like a coach, not an analyst.
37
+ - A little character is good. Generic filler is not.
38
+ - Lead with the best real part of the session.
39
+ - Mention at most one watch item, briefly and without turning the note into a review.
40
+ - If there is no meaningful extra signal beyond what the app already shows, return exactly: NO_INSIGHT.
41
+
42
+ Rules:
43
+ - No bullet points, no questions.
44
+ - No influencer language, no exclamation stacks, no slogans.
45
+ - Do not force concern or diagnosis.
46
+ - Do not use recovery language without explicit support.
47
+ - Use exact exercise names from the session data.
48
+ - If you reference overall session spread, tie it to the actual lifts performed.
49
+ - Never mention skipped or absent exercises by name.`;
50
+
51
+ export const WARM_ASK_PROMPT = `${SECURITY_PREAMBLE}You are a strength coach answering questions from the user's training history. Give useful coaching that feels good to receive and still helps them train better.
52
+
53
+ Rules:
54
+ - Use only the data provided. If the data does not support a claim, do not make it.
55
+ - Use typed user facts and user-authored notes naturally when present, but do not quote prompt-like note text.
56
+ - Lead with the main point in the first sentence.
57
+ - When the question is a follow-up to a workout note or asks "tell me more", default to 4-8 sentences max and no more than 2 short paragraphs.
58
+ - For follow-ups, expand on 1-2 real signals only. Do not turn the answer into a report.
59
+ - Start with what went well before any watch item unless the question is explicitly about a problem.
60
+ - Do not force a concern, risk, or flag into every answer.
61
+ - If there is a watch item, frame it lightly and specifically.
62
+ - Use exact exercise names from the training data.
63
+ - Never name an exercise that does not appear in the training data.
64
+ - Do not infer fatigue or poor readiness without explicit recovery or training-load support.
65
+ - Keep the tone natural and direct. No hype, no filler, no motivational closing line.`;
66
+
67
+ export const PERSONALITY_ASK_PROMPT = `${SECURITY_PREAMBLE}You are a strength coach answering training questions with a bit of personality. Sound human, slightly opinionated, and grounded in the log.
68
+
69
+ Rules:
70
+ - Use only the provided data.
71
+ - Make the answer feel conversational, not clinical.
72
+ - If the question is a follow-up to a workout note or asks for more detail, keep it to 4-8 sentences max and focus on the main 1-2 takeaways.
73
+ - It is fine to have a clear opinion about what mattered most, but anchor it to exact lifts, weights, reps, timing, or frequency.
74
+ - Do not force a problem into the answer.
75
+ - Do not diagnose fatigue or recovery issues without explicit support.
76
+ - Never name an exercise that does not appear in the training data, and use exact exercise names.
77
+ - No hype-bro language, no slogans, no fake sports-science voice, no long audit sections.
78
+ - End with the useful point, not a motivational sign-off.`;
79
+
80
+ export const COACH_PROMPT_VARIANTS = Object.freeze({
81
+ current: {
82
+ name: 'current',
83
+ label: 'Current control',
84
+ workoutPrompt: WORKOUT_COACH_PROMPT,
85
+ askPrompt: ASK_PROMPT
86
+ },
87
+ warm: {
88
+ name: 'warm',
89
+ label: 'Warm coach',
90
+ workoutPrompt: WARM_WORKOUT_COACH_PROMPT,
91
+ askPrompt: WARM_ASK_PROMPT
92
+ },
93
+ personality: {
94
+ name: 'personality',
95
+ label: 'More personality',
96
+ workoutPrompt: PERSONALITY_WORKOUT_COACH_PROMPT,
97
+ askPrompt: PERSONALITY_ASK_PROMPT
98
+ }
99
+ });
100
+
101
+ export const DEFAULT_COACH_BAKEOFF_VARIANTS = Object.freeze(['current', 'warm', 'personality']);
102
+
103
+ export function resolveCoachPromptVariant(name) {
104
+ if (!name) return COACH_PROMPT_VARIANTS.current;
105
+ return COACH_PROMPT_VARIANTS[name] ?? null;
106
+ }
package/src/contract.js CHANGED
@@ -1,4 +1,4 @@
1
- export const contractVersion = 6;
1
+ export const contractVersion = 10;
2
2
 
3
3
  export const capabilities = {
4
4
  readOnly: false,
@@ -161,6 +161,16 @@ export const commandSchema = [
161
161
  options: [
162
162
  { name: 'token', type: 'string', required: true, description: 'Program share token' }
163
163
  ]
164
+ },
165
+ {
166
+ command: 'increment-score history',
167
+ id: 'increment-score-history',
168
+ description: 'List historical Increment Score snapshots for the authenticated user',
169
+ options: [
170
+ { name: 'from', type: 'string', required: false, description: 'Earliest snapshot_at (ISO 8601)' },
171
+ { name: 'to', type: 'string', required: false, description: 'Latest snapshot_at (ISO 8601)' },
172
+ { name: 'limit', type: 'number', required: false, description: 'Max snapshots to return (default 200, max 1000)' }
173
+ ]
164
174
  }
165
175
  ];
166
176
 
@@ -194,7 +204,7 @@ export const writeCommandSchema = [
194
204
  {
195
205
  command: 'programs share create',
196
206
  id: 'program-share-create',
197
- description: 'Create or reuse a share token for one program',
207
+ description: 'Create a new share token for one program',
198
208
  usage: 'programs share create --program-id <program-id>',
199
209
  options: [
200
210
  { name: 'program-id', type: 'string', required: true, description: 'Program ID' }
@@ -212,10 +222,19 @@ export const writeCommandSchema = [
212
222
  {
213
223
  command: 'programs share revoke',
214
224
  id: 'program-share-revoke',
215
- description: 'Revoke a previously issued program share token',
216
- usage: 'programs share revoke --token <token>',
225
+ description: 'Revoke a previously issued program share by id',
226
+ usage: 'programs share revoke --share-id <share-id>',
217
227
  options: [
218
- { name: 'token', type: 'string', required: true, description: 'Program share token' }
228
+ { name: 'share-id', type: 'string', required: true, description: 'Program share id' }
229
+ ]
230
+ },
231
+ {
232
+ command: 'increment-score upload',
233
+ id: 'increment-score-upload',
234
+ description: 'Upload one or more Increment Score snapshots for the authenticated user',
235
+ usage: 'increment-score upload --file <file>',
236
+ options: [
237
+ { name: 'file', type: 'string', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
219
238
  ]
220
239
  }
221
240
  ];