incremnt 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,29 @@
1
1
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { anonymizeAccountId } from './anonymize.js';
3
+ import { formatIncrementScorePrelude } from './score-prelude.js';
4
+ import {
5
+ askVerificationMetadata,
6
+ buildAskAnswerRepairContext,
7
+ safeAskVerificationFallback,
8
+ shouldRepairAskAnswer,
9
+ verifyAskAnswer
10
+ } from './ask-answer-verifier.js';
11
+ import {
12
+ askMissingObservationFollowUpContext,
13
+ askObservationFollowUpContext,
14
+ askRoutedContext,
15
+ buildAskStructuredResponse,
16
+ coachFactKindsForAskQuestion,
17
+ normalizeObservationFollowUpIntent,
18
+ planAskEvidence,
19
+ sanitizeAskAnswerVerificationReceipt
20
+ } from './ask-coach.js';
3
21
  import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
4
- import { executeReadCommand } from './queries.js';
22
+ import { canonicalExerciseName, executeReadCommand } from './queries.js';
5
23
  import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
6
24
  import { enrichScoreSnapshots } from './score-context.js';
25
+ import { extractAskProgramDraft } from './program-draft.js';
26
+ import { extractPlanChangeset } from './plan-changeset.js';
7
27
 
8
28
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
9
29
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
@@ -15,6 +35,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
15
35
  'vitals-summary-ai': 3,
16
36
  'checkpoint-summary-ai': 3,
17
37
  'ask-ai': 5,
38
+ 'ask-plan': 30,
18
39
  'ai-feedback': 60,
19
40
  'coach-memory': 30,
20
41
  'weekly-checkin-enroll': 10,
@@ -25,6 +46,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
25
46
  'coach-observations-current': 30,
26
47
  'coach-observations-seen': 30,
27
48
  'coach-observations-dismiss': 30,
49
+ 'coach-observations-outcome': 30,
50
+ 'coach-observations-feedback': 30,
28
51
  'dev-login': 10,
29
52
  'device-start': 20,
30
53
  'device-poll': 300,
@@ -102,6 +125,173 @@ const DEFAULT_RATE_LIMIT_RULES = {
102
125
  'sync-push-device-register': 30,
103
126
  'sync-push-device-revoke': 30
104
127
  };
128
+ const ASK_HISTORY_MAX_MESSAGES = 20;
129
+ const ASK_HISTORY_MAX_MESSAGE_LENGTH = 2000;
130
+ const ASK_STRUCTURED_MAX_ITEMS = 10;
131
+ const ASK_STRUCTURED_MAX_STRING_LENGTH = 1000;
132
+ const ASK_STRUCTURED_MAX_JSON_LENGTH = 30000;
133
+ const COACH_OBSERVATION_OUTCOME_STATUSES = new Set(['improved', 'unchanged', 'regressed', 'inconclusive']);
134
+ const COACH_OBSERVATION_FEEDBACK_STATUSES = new Set(['accepted', 'rejected']);
135
+
136
+ function askStorageString(value, { maxLength = ASK_STRUCTURED_MAX_STRING_LENGTH } = {}) {
137
+ if (typeof value !== 'string') return null;
138
+ const trimmed = value.trim();
139
+ if (!trimmed) return null;
140
+ return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
141
+ }
142
+
143
+ function askStorageStringArray(value, { maxItems = ASK_STRUCTURED_MAX_ITEMS, maxLength = 240 } = {}) {
144
+ if (!Array.isArray(value)) return [];
145
+ return value
146
+ .map((item) => askStorageString(item, { maxLength }))
147
+ .filter(Boolean)
148
+ .slice(0, maxItems);
149
+ }
150
+
151
+ function evidenceLabel(section, toolName) {
152
+ const cleaned = String(section || toolName || 'evidence')
153
+ .replace(/^observation_/, '')
154
+ .replace(/_/g, ' ')
155
+ .trim();
156
+ return cleaned ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) : 'Evidence';
157
+ }
158
+
159
+ // Fold tools the agentic loop fetched at generation time into the routing
160
+ // metadata so toolsUsed, provenance, and the structured evidence list reflect
161
+ // what was actually read (keeps ask_tool_provenance honest). Mutates in place.
162
+ function mergeAgenticToolProvenance(routingMetadata, toolInvocations = []) {
163
+ if (!routingMetadata || !Array.isArray(toolInvocations) || toolInvocations.length === 0) return;
164
+ const names = toolInvocations.map((invocation) => invocation.name).filter(Boolean);
165
+ routingMetadata.toolsUsed = [...new Set([...(routingMetadata.toolsUsed ?? []), ...names])];
166
+ routingMetadata.toolParams = {
167
+ ...(routingMetadata.toolParams ?? {}),
168
+ ...Object.fromEntries(toolInvocations
169
+ .filter((invocation) => invocation?.name)
170
+ .map((invocation) => [invocation.name, invocation.params ?? {}]))
171
+ };
172
+ if (routingMetadata.evidencePlan && typeof routingMetadata.evidencePlan === 'object') {
173
+ routingMetadata.evidencePlan = {
174
+ ...routingMetadata.evidencePlan,
175
+ executedTools: [...new Set([...(routingMetadata.evidencePlan.executedTools ?? []), ...names])]
176
+ };
177
+ }
178
+ routingMetadata.agenticToolInvocations = toolInvocations.map((invocation) => ({
179
+ name: invocation.name,
180
+ params: invocation.params ?? {},
181
+ sourceIds: invocation.sourceIds ?? []
182
+ }));
183
+
184
+ const provenanceEntries = toolInvocations.map((invocation) => ({
185
+ section: invocation.name,
186
+ toolName: invocation.name,
187
+ sourceIds: invocation.sourceIds ?? []
188
+ }));
189
+ routingMetadata.provenance = [...(routingMetadata.provenance ?? []), ...provenanceEntries];
190
+
191
+ const contextBundle = routingMetadata.contextBundle;
192
+ if (contextBundle && typeof contextBundle === 'object') {
193
+ const evidenceEntries = provenanceEntries.map((entry) => ({
194
+ label: evidenceLabel(entry.section, entry.toolName),
195
+ section: entry.section,
196
+ toolName: entry.toolName,
197
+ sourceTimestamp: null,
198
+ sourceIds: entry.sourceIds,
199
+ noteSourceIds: [],
200
+ missingDataFlags: []
201
+ }));
202
+ routingMetadata.contextBundle = {
203
+ ...contextBundle,
204
+ executedTools: [...new Set([...(contextBundle.executedTools ?? []), ...names])],
205
+ evidenceUsed: [...(contextBundle.evidenceUsed ?? []), ...evidenceEntries]
206
+ };
207
+ }
208
+ }
209
+
210
+ function sanitizeAskEvidenceForStorage(item) {
211
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
212
+ const sanitized = {};
213
+ for (const key of ['label', 'section', 'toolName', 'sourceTimestamp']) {
214
+ const value = askStorageString(item[key], { maxLength: 240 });
215
+ if (value) sanitized[key] = value;
216
+ }
217
+ for (const key of ['sourceIds', 'noteSourceIds', 'missingDataFlags']) {
218
+ const values = askStorageStringArray(item[key], { maxItems: ASK_STRUCTURED_MAX_ITEMS, maxLength: 160 });
219
+ if (values.length > 0) sanitized[key] = values;
220
+ }
221
+ return Object.keys(sanitized).length > 0 ? sanitized : null;
222
+ }
223
+
224
+ function sanitizeAskActionForStorage(item) {
225
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
226
+ const id = askStorageString(item.id, { maxLength: 160 });
227
+ const label = askStorageString(item.label, { maxLength: 240 });
228
+ const kind = askStorageString(item.kind, { maxLength: 120 });
229
+ if (!id && !label) return null;
230
+ return {
231
+ ...(id ? { id } : {}),
232
+ label: label ?? id,
233
+ ...(kind ? { kind } : {})
234
+ };
235
+ }
236
+
237
+ function sanitizeAskProgramDraftForStorage(value) {
238
+ if (value == null) return null;
239
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
240
+ const serialized = JSON.stringify(value);
241
+ if (serialized.length > ASK_STRUCTURED_MAX_JSON_LENGTH) return null;
242
+ return JSON.parse(serialized);
243
+ }
244
+
245
+ function sanitizeAskStructuredResponseForStorage(structured) {
246
+ if (!structured || typeof structured !== 'object' || Array.isArray(structured)) return null;
247
+ const confidence = askStorageString(structured.confidence, { maxLength: 40 });
248
+ const answer = askStorageString(structured.answer);
249
+ const evidenceUsed = Array.isArray(structured.evidenceUsed)
250
+ ? structured.evidenceUsed.map(sanitizeAskEvidenceForStorage).filter(Boolean).slice(0, ASK_STRUCTURED_MAX_ITEMS)
251
+ : [];
252
+ const recommendedActions = Array.isArray(structured.recommendedActions)
253
+ ? structured.recommendedActions.map(sanitizeAskActionForStorage).filter(Boolean).slice(0, ASK_STRUCTURED_MAX_ITEMS)
254
+ : [];
255
+ const followUpSuggestions = askStorageStringArray(structured.followUpSuggestions, { maxItems: 5, maxLength: 240 });
256
+ const limitations = askStorageStringArray(structured.limitations, { maxItems: ASK_STRUCTURED_MAX_ITEMS, maxLength: 240 });
257
+ const answerVerification = sanitizeAskAnswerVerificationReceipt(structured.answerVerification);
258
+ const programDraft = sanitizeAskProgramDraftForStorage(structured.programDraft);
259
+
260
+ return {
261
+ ...(answer ? { answer } : {}),
262
+ ...(confidence ? { confidence } : {}),
263
+ evidenceUsed,
264
+ recommendedActions,
265
+ followUpSuggestions,
266
+ limitations,
267
+ ...(answerVerification ? { answerVerification } : {}),
268
+ programDraft
269
+ };
270
+ }
271
+
272
+ function sanitizeAskMessagesForStorage(messages, { allowStructured = false } = {}) {
273
+ if (!Array.isArray(messages)) return [];
274
+
275
+ const cleaned = messages
276
+ .filter((message) => message && ['user', 'assistant'].includes(message.role) && typeof message.content === 'string')
277
+ .map((message) => {
278
+ const item = {
279
+ role: message.role,
280
+ content: message.content.length > ASK_HISTORY_MAX_MESSAGE_LENGTH
281
+ ? message.content.slice(0, ASK_HISTORY_MAX_MESSAGE_LENGTH)
282
+ : message.content
283
+ };
284
+ if (allowStructured && message.role === 'assistant') {
285
+ const structured = sanitizeAskStructuredResponseForStorage(message.structured);
286
+ if (structured) item.structured = structured;
287
+ }
288
+ return item;
289
+ });
290
+
291
+ return cleaned.length > ASK_HISTORY_MAX_MESSAGES
292
+ ? cleaned.slice(cleaned.length - ASK_HISTORY_MAX_MESSAGES)
293
+ : cleaned;
294
+ }
105
295
 
106
296
  export function isNoInsightResponse(text) {
107
297
  const normalized = String(text ?? '')
@@ -178,6 +368,42 @@ function isoDateFromParts({ year, month, day }) {
178
368
  ].join('-');
179
369
  }
180
370
 
371
+ function isoDateParts(isoDate) {
372
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(isoDate ?? ''));
373
+ if (!match) return null;
374
+ return {
375
+ year: Number(match[1]),
376
+ month: Number(match[2]),
377
+ day: Number(match[3])
378
+ };
379
+ }
380
+
381
+ function weeklyCheckinContextOptions(row) {
382
+ const parts = isoDateParts(row?.weekStartDate);
383
+ if (!parts) return { now: new Date(`${row?.weekStartDate ?? ''}T23:59:59.999Z`) };
384
+ const timeZoneId = isValidTimeZone(row?.timezoneId) ? row.timezoneId : 'UTC';
385
+ const weekStartParts = addCalendarDays(parts, -7);
386
+ const nextDay = addCalendarDays(parts, 1);
387
+ const now = new Date(zonedDateTimeToUtc(timeZoneId, {
388
+ ...nextDay,
389
+ hour: 0,
390
+ minute: 0,
391
+ second: 0
392
+ }).getTime() - 1);
393
+ const cutoff = zonedDateTimeToUtc(timeZoneId, {
394
+ ...weekStartParts,
395
+ hour: 0,
396
+ minute: 0,
397
+ second: 0
398
+ });
399
+ return {
400
+ now,
401
+ todayIso: isoDateFromParts(parts),
402
+ weekStartIso: isoDateFromParts(weekStartParts),
403
+ cutoff
404
+ };
405
+ }
406
+
181
407
  export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
182
408
  if (!isValidTimeZone(timeZoneId)) {
183
409
  const err = new Error('Invalid timezoneId');
@@ -208,196 +434,6 @@ export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
208
434
  };
209
435
  }
210
436
 
211
- const PROGRAM_DRAFT_VERSION = 1;
212
- const VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS = new Set(['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly']);
213
- const VALID_PROGRAM_DRAFT_VOLUME_LEVELS = new Set(['minimum', 'moderate', 'high']);
214
-
215
- const PROGRAM_DRAFT_LIMITS = {
216
- nameMaxLen: 120,
217
- muscleGroupMaxLen: 60,
218
- dayLabelMaxLen: 60,
219
- dayTitleMaxLen: 120,
220
- daySubtitleMaxLen: 120,
221
- noteMaxLen: 1000,
222
- minWeight: 0,
223
- maxWeight: 600,
224
- minReps: 1,
225
- maxReps: 30,
226
- minRir: 0,
227
- maxRir: 5,
228
- minSetsPerExercise: 1,
229
- maxSetsPerExercise: 12,
230
- minExercisesPerDay: 1,
231
- maxExercisesPerDay: 24,
232
- minDaysPerWeek: 1,
233
- maxDaysPerWeek: 7,
234
- minDays: 1,
235
- maxDays: 14
236
- };
237
-
238
- function collapseBlankLines(text) {
239
- return String(text ?? '')
240
- .replace(/\n{3,}/g, '\n\n')
241
- .trim();
242
- }
243
-
244
- function titleCaseExerciseName(name) {
245
- return String(name ?? '')
246
- .split(' ')
247
- .filter(Boolean)
248
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
249
- .join(' ');
250
- }
251
-
252
- function normalizedExerciseDisplayName(name, canonicalizeExerciseName) {
253
- const trimmed = String(name ?? '').trim();
254
- if (!trimmed) return '';
255
- const canonical = canonicalizeExerciseName ? canonicalizeExerciseName(trimmed) : trimmed.toLowerCase();
256
- return titleCaseExerciseName(canonical);
257
- }
258
-
259
- function normalizeProgramDraftSet(set) {
260
- const weight = Number(set?.weight);
261
- const reps = Number(set?.reps);
262
- if (!Number.isFinite(weight) || !Number.isInteger(reps)) return null;
263
- if (
264
- weight < PROGRAM_DRAFT_LIMITS.minWeight ||
265
- weight > PROGRAM_DRAFT_LIMITS.maxWeight ||
266
- reps < PROGRAM_DRAFT_LIMITS.minReps ||
267
- reps > PROGRAM_DRAFT_LIMITS.maxReps
268
- ) return null;
269
- return {
270
- weight,
271
- reps,
272
- isComplete: false,
273
- isWarmup: set?.isWarmup === true
274
- };
275
- }
276
-
277
- function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName) {
278
- const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
279
- const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
280
- const sets = Array.isArray(exercise?.sets)
281
- ? exercise.sets.map(normalizeProgramDraftSet).filter(Boolean)
282
- : [];
283
-
284
- if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
285
- if (!muscleGroup || muscleGroup.length > PROGRAM_DRAFT_LIMITS.muscleGroupMaxLen) return null;
286
- if (
287
- sets.length < PROGRAM_DRAFT_LIMITS.minSetsPerExercise ||
288
- sets.length > PROGRAM_DRAFT_LIMITS.maxSetsPerExercise
289
- ) return null;
290
-
291
- const rir = exercise?.rir == null ? null : Number(exercise.rir);
292
- if (rir != null && (
293
- !Number.isInteger(rir) ||
294
- rir < PROGRAM_DRAFT_LIMITS.minRir ||
295
- rir > PROGRAM_DRAFT_LIMITS.maxRir
296
- )) return null;
297
-
298
- const note = exercise?.note == null ? null : String(exercise.note);
299
- if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
300
-
301
- return {
302
- name,
303
- muscleGroup,
304
- lastSuggestion: '',
305
- nextSuggestion: '',
306
- sets,
307
- ...(note ? { note } : {}),
308
- ...(rir != null ? { rir } : {})
309
- };
310
- }
311
-
312
- function normalizeProgramDraftDay(day, canonicalizeExerciseName) {
313
- const dayLabel = String(day?.dayLabel ?? '').trim();
314
- const title = String(day?.title ?? '').trim();
315
- const subtitle = String(day?.subtitle ?? '').trim();
316
- const exercises = Array.isArray(day?.exercises)
317
- ? day.exercises.map((exercise) => normalizeProgramDraftExercise(exercise, canonicalizeExerciseName)).filter(Boolean)
318
- : [];
319
-
320
- if (!dayLabel || dayLabel.length > PROGRAM_DRAFT_LIMITS.dayLabelMaxLen) return null;
321
- if (!title || title.length > PROGRAM_DRAFT_LIMITS.dayTitleMaxLen) return null;
322
- if (subtitle.length > PROGRAM_DRAFT_LIMITS.daySubtitleMaxLen) return null;
323
- if (
324
- exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
325
- exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
326
- ) return null;
327
-
328
- return { dayLabel, title, subtitle, exercises };
329
- }
330
-
331
- function normalizeProgramDraft(rawProgram, { canonicalizeExerciseName } = {}) {
332
- if (!rawProgram || typeof rawProgram !== 'object' || Array.isArray(rawProgram)) return null;
333
-
334
- const name = String(rawProgram.name ?? '').trim();
335
- const days = Array.isArray(rawProgram.days)
336
- ? rawProgram.days.map((day) => normalizeProgramDraftDay(day, canonicalizeExerciseName)).filter(Boolean)
337
- : [];
338
- const daysPerWeek = Number(rawProgram.daysPerWeek);
339
- const currentDayIndex = rawProgram.currentDayIndex == null ? 0 : Number(rawProgram.currentDayIndex);
340
- const equipmentTier = String(rawProgram.equipmentTier ?? 'fullGym').trim();
341
- const volumeLevel = String(rawProgram.volumeLevel ?? 'moderate').trim();
342
-
343
- if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
344
- if (days.length < PROGRAM_DRAFT_LIMITS.minDays || days.length > PROGRAM_DRAFT_LIMITS.maxDays) return null;
345
- if (
346
- !Number.isInteger(daysPerWeek) ||
347
- daysPerWeek < PROGRAM_DRAFT_LIMITS.minDaysPerWeek ||
348
- daysPerWeek > PROGRAM_DRAFT_LIMITS.maxDaysPerWeek
349
- ) return null;
350
- if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= days.length) return null;
351
- if (!VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS.has(equipmentTier) || !VALID_PROGRAM_DRAFT_VOLUME_LEVELS.has(volumeLevel)) return null;
352
-
353
- return {
354
- name,
355
- daysPerWeek,
356
- equipmentTier,
357
- volumeLevel,
358
- source: 'guided',
359
- days,
360
- currentDayIndex
361
- };
362
- }
363
-
364
- function extractAskProgramDraft(rawText, { canonicalizeExerciseName } = {}) {
365
- const text = String(rawText ?? '');
366
- const match = text.match(/<program_draft>\s*([\s\S]*?)\s*<\/program_draft>/i);
367
- if (!match) {
368
- return { answerText: text.trim(), programDraft: null };
369
- }
370
-
371
- const answerText = collapseBlankLines(text.replace(match[0], ''));
372
- let parsed;
373
- try {
374
- parsed = JSON.parse(match[1]);
375
- } catch (err) {
376
- console.warn('askCoach: <program_draft> JSON parse failed — dropping draft:', err.message);
377
- return { answerText, programDraft: null };
378
- }
379
-
380
- const program = normalizeProgramDraft(parsed, { canonicalizeExerciseName });
381
- if (!program) {
382
- console.warn('askCoach: <program_draft> payload failed validation — dropping draft');
383
- return { answerText, programDraft: null };
384
- }
385
-
386
- return {
387
- answerText,
388
- programDraft: {
389
- program,
390
- provenance: {
391
- source: 'ai-coach',
392
- type: 'program',
393
- version: PROGRAM_DRAFT_VERSION,
394
- createdAt: new Date().toISOString(),
395
- tokenHint: null
396
- }
397
- }
398
- };
399
- }
400
-
401
437
  function json(response, statusCode, payload) {
402
438
  response.writeHead(statusCode, { 'content-type': 'application/json' });
403
439
  response.end(JSON.stringify(payload));
@@ -514,7 +550,132 @@ function currentAIGitSha() {
514
550
  ?? null;
515
551
  }
516
552
 
553
+ function compactLogObject(obj) {
554
+ return Object.fromEntries(
555
+ Object.entries(obj).filter(([, value]) => {
556
+ if (value === undefined || value === null) return false;
557
+ if (Array.isArray(value)) return value.length > 0;
558
+ return true;
559
+ })
560
+ );
561
+ }
562
+
563
+ function logStringArray(values, { max = 12 } = {}) {
564
+ const unique = [];
565
+ const seen = new Set();
566
+ for (const value of Array.isArray(values) ? values : []) {
567
+ const text = typeof value === 'string' ? value.trim() : '';
568
+ if (!text || seen.has(text)) continue;
569
+ seen.add(text);
570
+ unique.push(text);
571
+ if (unique.length >= max) break;
572
+ }
573
+ return unique;
574
+ }
575
+
576
+ export function buildAskInteractionLogPayload({
577
+ accountId = null,
578
+ status = 'ok',
579
+ promptSurface = 'ask',
580
+ routingMetadata = {},
581
+ askResult = {},
582
+ structured = {}
583
+ } = {}) {
584
+ const evidencePlan = routingMetadata?.evidencePlan ?? {};
585
+ const contextBundle = routingMetadata?.contextBundle ?? {};
586
+ const missingDataFlags = logStringArray([
587
+ ...(routingMetadata?.missingDataFlags ?? []),
588
+ ...(contextBundle?.missingDataFlags ?? []),
589
+ ...(evidencePlan?.evidenceGaps ?? [])
590
+ ]);
591
+ const evidenceUsed = Array.isArray(structured?.evidenceUsed) ? structured.evidenceUsed : [];
592
+ const recommendedActions = Array.isArray(structured?.recommendedActions) ? structured.recommendedActions : [];
593
+ const answerVerification = routingMetadata?.answerVerification ?? {};
594
+
595
+ return compactLogObject({
596
+ event: 'ask_coach_interaction',
597
+ surface: promptSurface,
598
+ status,
599
+ aid: accountId ? anonymizeAccountId(accountId) : undefined,
600
+ model: askResult?.model ?? undefined,
601
+ durationMs: typeof askResult?.durationMs === 'number' ? askResult.durationMs : undefined,
602
+ fallback: askResult?.fallback === true ? true : undefined,
603
+ route: routingMetadata?.route ?? evidencePlan?.route,
604
+ effectiveRoute: routingMetadata?.effectiveRoute ?? evidencePlan?.effectiveRoute,
605
+ requestedAction: routingMetadata?.intent?.requestedAction,
606
+ intentConfidence: typeof routingMetadata?.intent?.confidence === 'number' ? routingMetadata.intent.confidence : undefined,
607
+ structuredConfidence: typeof structured?.confidence === 'string' ? structured.confidence : undefined,
608
+ contextCharCount: typeof routingMetadata?.contextCharCount === 'number' ? routingMetadata.contextCharCount : undefined,
609
+ historyTurnCount: typeof routingMetadata?.historyTurnCount === 'number' ? routingMetadata.historyTurnCount : undefined,
610
+ requiredTools: logStringArray(evidencePlan?.requiredTools),
611
+ executedTools: logStringArray(evidencePlan?.executedTools ?? routingMetadata?.toolsUsed ?? contextBundle?.executedTools),
612
+ evidenceGaps: logStringArray(evidencePlan?.evidenceGaps),
613
+ missingDataFlags,
614
+ evidenceUsedLabels: logStringArray(evidenceUsed.map((item) => item?.label)),
615
+ evidenceUsedTools: logStringArray(evidenceUsed.map((item) => item?.toolName)),
616
+ recommendedActionIds: logStringArray(recommendedActions.map((item) => item?.id)),
617
+ recommendedActionLabels: logStringArray(recommendedActions.map((item) => item?.label)),
618
+ followUpSuggestionCount: Array.isArray(structured?.followUpSuggestions) ? structured.followUpSuggestions.length : undefined,
619
+ limitationCount: Array.isArray(structured?.limitations) ? structured.limitations.length : undefined,
620
+ hasProgramDraft: structured?.programDraft != null ? true : undefined,
621
+ askVerificationStatus: answerVerification.status,
622
+ askVerificationRetryCount: typeof answerVerification.retryCount === 'number' ? answerVerification.retryCount : undefined,
623
+ askVerificationBlockingFailureCount: typeof answerVerification.blockingFailureCount === 'number' ? answerVerification.blockingFailureCount : undefined,
624
+ askVerificationAdvisoryFailureCount: typeof answerVerification.advisoryFailureCount === 'number' ? answerVerification.advisoryFailureCount : undefined,
625
+ askVerificationFailureKeys: logStringArray(answerVerification.failureKeys),
626
+ coachFactsIncluded: routingMetadata?.coachFactsIncluded === true ? true : undefined,
627
+ coachFactCount: Array.isArray(routingMetadata?.includedCoachFactIds)
628
+ ? routingMetadata.includedCoachFactIds.length
629
+ : Array.isArray(routingMetadata?.coachFactIds)
630
+ ? routingMetadata.coachFactIds.length
631
+ : undefined,
632
+ coachObservationCount: Array.isArray(routingMetadata?.includedCoachObservationIds)
633
+ ? routingMetadata.includedCoachObservationIds.length
634
+ : Array.isArray(routingMetadata?.coachObservationIds)
635
+ ? routingMetadata.coachObservationIds.length
636
+ : undefined,
637
+ langfuseTraceId: askResult?.langfuseTraceId,
638
+ langfuseObservationId: askResult?.langfuseObservationId
639
+ });
640
+ }
641
+
642
+ function shouldRequireProgramDraftForAsk({ persistedKind, requestedCoachObservation, missingRequestedCoachObservation }) {
643
+ return persistedKind === 'ask' &&
644
+ requestedCoachObservation?.intent === 'successor_plan' &&
645
+ !missingRequestedCoachObservation;
646
+ }
647
+
648
+ function shouldRequirePlanChangesetForAsk({ persistedKind, requestedCoachObservation, missingRequestedCoachObservation }) {
649
+ return persistedKind === 'ask' &&
650
+ requestedCoachObservation?.intent === 'plan_adjustment' &&
651
+ !missingRequestedCoachObservation;
652
+ }
653
+
654
+ function buildMissingProgramDraftRepairContext(context) {
655
+ return `${context}
656
+
657
+ Program draft repair:
658
+ A program draft was required for this matched successor plan request, but the previous answer only gave prose.
659
+ Re-answer with 1-2 short prose sentences and exactly one trailing <program_draft>{JSON}</program_draft> block.
660
+ The JSON must be a complete Program object using the schema already specified above.
661
+ Do not ask a follow-up question unless the evidence is weak, stale, or contradicted by the tools above.`;
662
+ }
663
+
664
+ function buildMissingPlanChangesetRepairContext(context) {
665
+ return `${context}
666
+
667
+ Plan changeset repair:
668
+ A plan changeset was required for this matched plan adjustment request, but the previous answer only gave prose.
669
+ Re-answer with 1-2 short prose sentences and exactly one trailing <plan_changeset>{JSON}</plan_changeset> block.
670
+ The JSON must use the Plan adjustment request schema already specified above.
671
+ Do not include concrete weights, reps, set counts, or deltas.
672
+ Do not ask a follow-up question unless the evidence is weak, stale, or contradicted by the tools above.`;
673
+ }
674
+
517
675
  function buildAIGenerationMetadata(surface, model, promptVersion, generation = {}) {
676
+ const routing = generation.routingMetadata && typeof generation.routingMetadata === 'object'
677
+ ? generation.routingMetadata
678
+ : {};
518
679
  return {
519
680
  surface,
520
681
  generatedAt: new Date().toISOString(),
@@ -522,7 +683,15 @@ function buildAIGenerationMetadata(surface, model, promptVersion, generation = {
522
683
  promptVersion: promptVersion ?? null,
523
684
  gitSha: currentAIGitSha(),
524
685
  langfuseTraceId: generation.langfuseTraceId ?? null,
525
- langfuseObservationId: generation.langfuseObservationId ?? null
686
+ langfuseObservationId: generation.langfuseObservationId ?? null,
687
+ ...(typeof routing.route === 'string' ? { route: routing.route } : {}),
688
+ ...(typeof routing.effectiveRoute === 'string' ? { effectiveRoute: routing.effectiveRoute } : {}),
689
+ ...(typeof routing.observationFollowUpIntent === 'string' ? { observationFollowUpIntent: routing.observationFollowUpIntent } : {}),
690
+ ...(typeof routing.requestedCoachObservationIntent === 'string' ? { requestedCoachObservationIntent: routing.requestedCoachObservationIntent } : {}),
691
+ ...(typeof routing.requestedCoachObservationId === 'string' ? { requestedCoachObservationId: routing.requestedCoachObservationId } : {}),
692
+ ...(Array.isArray(routing.coachObservationIds) && routing.coachObservationIds.length > 0
693
+ ? { coachObservationIds: routing.coachObservationIds }
694
+ : {})
526
695
  };
527
696
  }
528
697
 
@@ -588,6 +757,23 @@ function forbidden(response, message = 'Forbidden') {
588
757
  json(response, 403, { error: message });
589
758
  }
590
759
 
760
+ function insufficientScope(response, request, requiredAccess = 'write') {
761
+ if (request) logRequest(request, 403);
762
+ json(response, 403, {
763
+ error: `Insufficient token scope. This endpoint requires ${requiredAccess} access.`,
764
+ code: 'INSUFFICIENT_SCOPE',
765
+ requiredAccess
766
+ });
767
+ }
768
+
769
+ async function rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator, requiredAccess = 'write') {
770
+ if (!requestToken || !readAuthenticator) return false;
771
+ const readAccount = await readAuthenticator(requestToken);
772
+ if (!readAccount) return false;
773
+ insufficientScope(response, request, requiredAccess);
774
+ return true;
775
+ }
776
+
591
777
  function methodNotAllowed(response, message = 'Method not allowed') {
592
778
  json(response, 405, { error: message });
593
779
  }
@@ -690,6 +876,67 @@ function parseLimit(value, { defaultValue, max }) {
690
876
  return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : defaultValue;
691
877
  }
692
878
 
879
+ function coachToolObservationOptions(toolName, body = {}) {
880
+ if (toolName === 'get_current_coach_observations') {
881
+ return {
882
+ limit: parseLimit(body?.limit, { defaultValue: 5, max: 20 }),
883
+ includeDismissed: Boolean(body?.includeDismissed),
884
+ includeOutcomeHistory: Boolean(body?.includeOutcomeHistory)
885
+ };
886
+ }
887
+ if (toolName === 'compare_session_to_observations') {
888
+ return {
889
+ limit: parseLimit(body?.observationLimit, { defaultValue: 5, max: 20 }),
890
+ includeDismissed: false,
891
+ includeOutcomeHistory: Boolean(body?.includeOutcomeHistory)
892
+ };
893
+ }
894
+ return null;
895
+ }
896
+
897
+ function coachObservationSourceTriggerForScoreSnapshots(snapshots) {
898
+ if (!Array.isArray(snapshots)) return undefined;
899
+ const reasons = snapshots.map((snapshot) => snapshot?.triggerReason);
900
+ if (reasons.length === 0 || reasons.some((reason) => !reason)) return undefined;
901
+ if (reasons.includes('sessionSave')) return 'session_completed';
902
+ if (reasons.every((reason) => reason === 'historicalBackfill')) {
903
+ return 'manual_backfill';
904
+ }
905
+ if (reasons.every((reason) => reason === 'dailyRefresh')) {
906
+ return 'daily_refresh';
907
+ }
908
+ return undefined;
909
+ }
910
+
911
+ function coachObservationGenerationMetadata(result, sourceTrigger) {
912
+ return {
913
+ attempted: true,
914
+ generated: Number(result?.generated ?? 0),
915
+ persisted: Number(result?.persisted ?? 0),
916
+ skippedReason: result?.skippedReason ?? null,
917
+ sourceTrigger: sourceTrigger ?? null
918
+ };
919
+ }
920
+
921
+ function normalizeAskCoachObservationFollowUp(value) {
922
+ if (!value || typeof value !== 'object') return null;
923
+ const id = String(value.id ?? '').trim();
924
+ if (!id) return null;
925
+ const intent = normalizeObservationFollowUpIntent(value.intent);
926
+ return {
927
+ id: id.slice(0, 128),
928
+ ...(intent ? { intent } : {})
929
+ };
930
+ }
931
+
932
+ function selectAskCoachObservationFollowUp(requested, observations) {
933
+ if (!requested) return null;
934
+ return (Array.isArray(observations) ? observations : [])
935
+ .find((observation) => String(observation?.id ?? '') === requested.id);
936
+ }
937
+
938
+ const ASK_REQUESTED_COACH_OBSERVATION_LIMIT = 20;
939
+
693
940
  function routeRequest(url, method) {
694
941
  const pathname = url.pathname;
695
942
 
@@ -761,6 +1008,22 @@ function routeRequest(url, method) {
761
1008
  return { command: 'contract', options: {} };
762
1009
  }
763
1010
 
1011
+ if (pathname === '/cli/agent-tokens') {
1012
+ return { command: 'agent-tokens', options: {} };
1013
+ }
1014
+
1015
+ {
1016
+ const agentTokenMatch = pathname.match(/^\/cli\/agent-tokens\/([^/]+)$/);
1017
+ if (agentTokenMatch) {
1018
+ return {
1019
+ command: 'agent-token',
1020
+ options: {
1021
+ id: decodeURIComponent(agentTokenMatch[1])
1022
+ }
1023
+ };
1024
+ }
1025
+ }
1026
+
764
1027
  if (pathname === '/sync/snapshot') {
765
1028
  return { command: 'sync-upload', options: {} };
766
1029
  }
@@ -908,11 +1171,34 @@ function routeRequest(url, method) {
908
1171
  return { command: 'program-detail', options: {} };
909
1172
  }
910
1173
 
1174
+ if (pathname === '/cli/programs/progress') {
1175
+ return {
1176
+ command: 'program-progress',
1177
+ options: {
1178
+ 'program-id': url.searchParams.get('program-id') ?? undefined,
1179
+ since: url.searchParams.get('since') ?? undefined,
1180
+ limitExercises: url.searchParams.get('limitExercises') ?? undefined
1181
+ }
1182
+ };
1183
+ }
1184
+
911
1185
  const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
912
1186
  if (programShowMatch) {
913
1187
  return { command: 'program-detail', options: { id: programShowMatch[1] } };
914
1188
  }
915
1189
 
1190
+ if (pathname === '/cli/exercises/progress') {
1191
+ return {
1192
+ command: 'exercise-progress-summary',
1193
+ options: {
1194
+ name: url.searchParams.get('name') ?? undefined,
1195
+ since: url.searchParams.get('since') ?? undefined,
1196
+ 'program-id': url.searchParams.get('program-id') ?? undefined,
1197
+ limit: url.searchParams.get('limit') ?? undefined
1198
+ }
1199
+ };
1200
+ }
1201
+
916
1202
  if (pathname === '/cli/exercises/history') {
917
1203
  return {
918
1204
  command: 'exercise-history',
@@ -944,6 +1230,16 @@ function routeRequest(url, method) {
944
1230
  };
945
1231
  }
946
1232
 
1233
+ if (pathname === '/cli/cycles/progress') {
1234
+ return {
1235
+ command: 'cycle-progression-summary',
1236
+ options: {
1237
+ 'program-id': url.searchParams.get('program-id') ?? undefined,
1238
+ limit: url.searchParams.get('limit') ?? undefined
1239
+ }
1240
+ };
1241
+ }
1242
+
947
1243
  const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
948
1244
  if (cyclesShowMatch) {
949
1245
  return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
@@ -1028,6 +1324,10 @@ function routeRequest(url, method) {
1028
1324
  return { command: 'ask-ai', options: {} };
1029
1325
  }
1030
1326
 
1327
+ if (pathname === '/cli/ask/plan') {
1328
+ return { command: 'ask-plan', options: {} };
1329
+ }
1330
+
1031
1331
  if (pathname === '/cli/ask/history') {
1032
1332
  return { command: 'ask-history', options: { limit: url.searchParams.get('limit') ?? undefined } };
1033
1333
  }
@@ -1064,11 +1364,17 @@ function routeRequest(url, method) {
1064
1364
  }
1065
1365
 
1066
1366
  if (pathname === '/cli/coach-observations/current') {
1067
- return { command: 'coach-observations-current', options: { limit: url.searchParams.get('limit') ?? undefined } };
1367
+ return {
1368
+ command: 'coach-observations-current',
1369
+ options: {
1370
+ limit: url.searchParams.get('limit') ?? undefined,
1371
+ refresh: url.searchParams.get('refresh') ?? undefined
1372
+ }
1373
+ };
1068
1374
  }
1069
1375
 
1070
1376
  {
1071
- const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
1377
+ const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss|outcome|feedback)$/);
1072
1378
  if (coachObservationActionMatch) {
1073
1379
  return {
1074
1380
  command: `coach-observations-${coachObservationActionMatch[2]}`,
@@ -1099,6 +1405,15 @@ function routeRequest(url, method) {
1099
1405
  return { command: 'training-load', options: {} };
1100
1406
  }
1101
1407
 
1408
+ if (pathname === '/cli/training-profile') {
1409
+ return {
1410
+ command: 'training-profile',
1411
+ options: {
1412
+ since: url.searchParams.get('since') ?? undefined
1413
+ }
1414
+ };
1415
+ }
1416
+
1102
1417
  // --- Social endpoints ---
1103
1418
  if (pathname === '/cli/social/me/profile') {
1104
1419
  return { command: 'social-me-profile', options: {} };
@@ -1463,55 +1778,10 @@ function routeRequest(url, method) {
1463
1778
  return null;
1464
1779
  }
1465
1780
 
1466
- export function formatIncrementScorePrelude(snapshots) {
1467
- if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1468
- const latest = snapshots[0];
1469
- if (latest == null || typeof latest.score !== 'number') return null;
1470
-
1471
- const lines = ['[Increment Score]'];
1472
- const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
1473
- lines.push(`- Current: ${latest.score}/100${tier}`);
1474
-
1475
- if (latest.components && typeof latest.components === 'object') {
1476
- const parts = [];
1477
- for (const [name, value] of Object.entries(latest.components)) {
1478
- const num = typeof value === 'number' ? value : value?.score;
1479
- if (typeof num === 'number') parts.push(`${name} ${num}`);
1480
- }
1481
- if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
1482
- }
1483
-
1484
- const driverLabels = (list) => {
1485
- if (!Array.isArray(list) || list.length === 0) return null;
1486
- return list
1487
- .slice(0, 3)
1488
- .map((d) => d?.label ?? d?.id ?? d?.driver)
1489
- .filter(Boolean)
1490
- .join('; ');
1491
- };
1492
- const positives = driverLabels(latest.topPositiveDrivers);
1493
- if (positives) lines.push(`- Top positive drivers: ${positives}`);
1494
- const negatives = driverLabels(latest.topNegativeDrivers);
1495
- if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
1496
-
1497
- if (snapshots.length > 1) {
1498
- const prior = snapshots[1];
1499
- if (typeof prior?.score === 'number') {
1500
- const delta = latest.score - prior.score;
1501
- const sign = delta > 0 ? '+' : '';
1502
- lines.push(`- Day-over-day delta: ${sign}${delta}`);
1503
- }
1504
- const recent = snapshots
1505
- .slice(0, 7)
1506
- .map((s) => (typeof s?.score === 'number' ? s.score : null))
1507
- .filter((s) => s != null);
1508
- if (recent.length >= 3) {
1509
- lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
1510
- }
1511
- }
1512
-
1513
- return lines.join('\n');
1514
- }
1781
+ // formatIncrementScorePrelude lives in score-prelude.js (no server deps) so the
1782
+ // eval harness can build the same context. Imported at the top; re-exported here
1783
+ // for existing importers (e.g. cli.test.js, the ask handler below).
1784
+ export { formatIncrementScorePrelude };
1515
1785
 
1516
1786
  async function readJsonBody(request) {
1517
1787
  const chunks = [];
@@ -1937,7 +2207,8 @@ export function syncServiceContractPayload({
1937
2207
  tokenBootstrap: true,
1938
2208
  deviceFlow: false,
1939
2209
  browserApproval: false,
1940
- devEmail: false
2210
+ devEmail: false,
2211
+ agentTokens: false
1941
2212
  },
1942
2213
  providers = {
1943
2214
  apple: {
@@ -2000,6 +2271,10 @@ export function createSyncServiceRequestHandler({
2000
2271
  completeGoogleWebAuth = null,
2001
2272
  completeGoogleMobileAuth = null,
2002
2273
  refreshSession,
2274
+ issueAgentToken,
2275
+ listAgentTokens,
2276
+ revokeAgentToken,
2277
+ authenticateAgentTokenManagement,
2003
2278
  authenticateConnectedWriteToken,
2004
2279
  allowManualDeviceApproval = false,
2005
2280
  rateLimitConfig = null,
@@ -2025,6 +2300,7 @@ export function createSyncServiceRequestHandler({
2025
2300
  getCurrentWeeklyCheckinForAccount = null,
2026
2301
  upsertScheduledWeeklyCheckinForAccount = null,
2027
2302
  transitionWeeklyCheckinForAccount = null,
2303
+ updateWeeklyCheckinRecapForAccount = null,
2028
2304
  generateWeeklyCheckinRecapImpl = null,
2029
2305
  generateCheckinQuestionsImpl = null,
2030
2306
  saveAIFeedbackForAccount = null,
@@ -2037,13 +2313,25 @@ export function createSyncServiceRequestHandler({
2037
2313
  insertScoreSnapshotsForAccount = null,
2038
2314
  listScoreSnapshotsForAccount = null,
2039
2315
  getCurrentWeeklyScoreDigestForAccount = null,
2316
+ generateCoachObservationsForAccount = null,
2040
2317
  listCurrentCoachObservationsForAccount = null,
2041
2318
  markCoachObservationSeenForAccount = null,
2042
2319
  dismissCoachObservationForAccount = null,
2320
+ recordCoachObservationOutcomeForAccount = null,
2321
+ recordCoachObservationFeedbackForAccount = null,
2043
2322
  // Social
2044
2323
  social = null,
2045
2324
  onError = null
2046
2325
  }) {
2326
+ // Fail-closed invariant: agent-token management is a human-only, full/session
2327
+ // scoped operation. If management is enabled, it MUST be gated by a dedicated
2328
+ // authenticator. Without this guard, the per-request `?? readAuthenticator`
2329
+ // fallback below would let the read authenticator (which accepts agent tokens)
2330
+ // manage tokens — a read-token → write-token escalation. Boot loudly instead.
2331
+ if ((issueAgentToken || listAgentTokens || revokeAgentToken) && typeof authenticateAgentTokenManagement !== 'function') {
2332
+ throw new Error('authenticateAgentTokenManagement must be provided when agent-token management is enabled; it must not fall back to the read authenticator.');
2333
+ }
2334
+
2047
2335
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
2048
2336
 
2049
2337
  return async function handle(request, response) {
@@ -2840,65 +3128,162 @@ export function createSyncServiceRequestHandler({
2840
3128
  const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
2841
3129
  const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
2842
3130
  const mobileSyncAuthenticator = writeAuthenticator;
3131
+ // Safe fallback: the construction-time invariant guarantees a dedicated
3132
+ // authenticator whenever management handlers are wired, so this only ever
3133
+ // resolves to readAuthenticator when management is disabled (issueAgentToken
3134
+ // absent), where the route returns 405 before any token is honoured.
3135
+ const agentTokenManagementAuthenticator = authenticateAgentTokenManagement ?? readAuthenticator;
2843
3136
 
2844
- if (route.command === 'mobile-sync-bootstrap') {
2845
- if (request.method !== 'GET') {
2846
- methodNotAllowed(response, 'Use GET for /mobile/sync/bootstrap.');
3137
+ if (route.command === 'agent-tokens') {
3138
+ if (request.method !== 'POST' && request.method !== 'GET') {
3139
+ methodNotAllowed(response, 'Use GET or POST for /cli/agent-tokens.');
2847
3140
  return;
2848
3141
  }
2849
- if (!loadMobileSyncStateForAccount) {
2850
- methodNotAllowed(response, 'Mobile sync bootstrap is not enabled for this service mode.');
3142
+
3143
+ if (!requestToken) {
3144
+ unauthorized(response, request);
2851
3145
  return;
2852
3146
  }
2853
3147
 
2854
- const account = mobileSyncAuthenticator
2855
- ? await mobileSyncAuthenticator(requestToken)
3148
+ const managementAccount = agentTokenManagementAuthenticator
3149
+ ? await agentTokenManagementAuthenticator(requestToken)
2856
3150
  : null;
2857
- if (!account) {
3151
+ if (!managementAccount) {
2858
3152
  unauthorized(response, request);
2859
3153
  return;
2860
3154
  }
2861
3155
 
2862
- json(response, 200, await loadMobileSyncStateForAccount(account));
3156
+ if (request.method === 'POST') {
3157
+ if (!issueAgentToken) {
3158
+ methodNotAllowed(response, 'Agent token creation is not enabled for this service mode.');
3159
+ return;
3160
+ }
3161
+ try {
3162
+ const body = await readJsonBody(request);
3163
+ const agentToken = await issueAgentToken(managementAccount, {
3164
+ name: body?.name,
3165
+ access: body?.access ?? 'read',
3166
+ expiresDays: body?.expiresDays ?? 90
3167
+ });
3168
+ json(response, 201, {
3169
+ ok: true,
3170
+ agentToken
3171
+ });
3172
+ return;
3173
+ } catch (error) {
3174
+ badRequest(response, error.message);
3175
+ return;
3176
+ }
3177
+ }
3178
+
3179
+ if (!listAgentTokens) {
3180
+ methodNotAllowed(response, 'Agent token listing is not enabled for this service mode.');
3181
+ return;
3182
+ }
3183
+ const agentTokens = await listAgentTokens(managementAccount);
3184
+ json(response, 200, {
3185
+ agentTokens
3186
+ });
2863
3187
  return;
2864
3188
  }
2865
3189
 
2866
- if (route.command === 'mobile-sync-pull') {
2867
- if (request.method !== 'GET') {
2868
- methodNotAllowed(response, 'Use GET for /mobile/sync/pull.');
3190
+ if (route.command === 'agent-token') {
3191
+ if (request.method !== 'DELETE') {
3192
+ methodNotAllowed(response, 'Use DELETE for /cli/agent-tokens/:id.');
2869
3193
  return;
2870
3194
  }
2871
- if (!pullMobileSyncStateForAccount) {
2872
- methodNotAllowed(response, 'Mobile sync pull is not enabled for this service mode.');
3195
+
3196
+ if (!requestToken) {
3197
+ unauthorized(response, request);
2873
3198
  return;
2874
3199
  }
2875
3200
 
2876
- const account = mobileSyncAuthenticator
2877
- ? await mobileSyncAuthenticator(requestToken)
3201
+ if (!revokeAgentToken) {
3202
+ methodNotAllowed(response, 'Agent token revocation is not enabled for this service mode.');
3203
+ return;
3204
+ }
3205
+
3206
+ const managementAccount = agentTokenManagementAuthenticator
3207
+ ? await agentTokenManagementAuthenticator(requestToken)
2878
3208
  : null;
2879
- if (!account) {
3209
+ if (!managementAccount) {
2880
3210
  unauthorized(response, request);
2881
3211
  return;
2882
3212
  }
2883
3213
 
2884
- json(response, 200, await pullMobileSyncStateForAccount(account, route.options.since));
2885
- return;
2886
- }
2887
-
2888
- if (route.command === 'score-snapshots') {
2889
- const account = connectedWriteAuthenticator
2890
- ? await connectedWriteAuthenticator(requestToken)
3214
+ const agentToken = await revokeAgentToken(managementAccount, route.options.id);
3215
+ if (!agentToken) {
3216
+ notFound(response, 'Agent token not found.');
3217
+ return;
3218
+ }
3219
+
3220
+ json(response, 200, {
3221
+ ok: true,
3222
+ agentToken
3223
+ });
3224
+ return;
3225
+ }
3226
+
3227
+ if (route.command === 'mobile-sync-bootstrap') {
3228
+ if (request.method !== 'GET') {
3229
+ methodNotAllowed(response, 'Use GET for /mobile/sync/bootstrap.');
3230
+ return;
3231
+ }
3232
+ if (!loadMobileSyncStateForAccount) {
3233
+ methodNotAllowed(response, 'Mobile sync bootstrap is not enabled for this service mode.');
3234
+ return;
3235
+ }
3236
+
3237
+ const account = mobileSyncAuthenticator
3238
+ ? await mobileSyncAuthenticator(requestToken)
2891
3239
  : null;
2892
3240
  if (!account) {
2893
3241
  unauthorized(response, request);
2894
3242
  return;
2895
3243
  }
2896
3244
 
3245
+ json(response, 200, await loadMobileSyncStateForAccount(account));
3246
+ return;
3247
+ }
3248
+
3249
+ if (route.command === 'mobile-sync-pull') {
3250
+ if (request.method !== 'GET') {
3251
+ methodNotAllowed(response, 'Use GET for /mobile/sync/pull.');
3252
+ return;
3253
+ }
3254
+ if (!pullMobileSyncStateForAccount) {
3255
+ methodNotAllowed(response, 'Mobile sync pull is not enabled for this service mode.');
3256
+ return;
3257
+ }
3258
+
3259
+ const account = mobileSyncAuthenticator
3260
+ ? await mobileSyncAuthenticator(requestToken)
3261
+ : null;
3262
+ if (!account) {
3263
+ unauthorized(response, request);
3264
+ return;
3265
+ }
3266
+
3267
+ json(response, 200, await pullMobileSyncStateForAccount(account, route.options.since));
3268
+ return;
3269
+ }
3270
+
3271
+ if (route.command === 'score-snapshots') {
2897
3272
  if (request.method === 'POST') {
2898
3273
  if (!insertScoreSnapshotsForAccount) {
2899
3274
  methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
2900
3275
  return;
2901
3276
  }
3277
+ const account = connectedWriteAuthenticator
3278
+ ? await connectedWriteAuthenticator(requestToken)
3279
+ : null;
3280
+ if (!account) {
3281
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3282
+ return;
3283
+ }
3284
+ unauthorized(response, request);
3285
+ return;
3286
+ }
2902
3287
  try {
2903
3288
  const body = await readJsonBody(request);
2904
3289
  if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
@@ -2906,6 +3291,22 @@ export function createSyncServiceRequestHandler({
2906
3291
  return;
2907
3292
  }
2908
3293
  const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
3294
+ if (generateCoachObservationsForAccount) {
3295
+ const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
3296
+ try {
3297
+ const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
3298
+ result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3299
+ } catch (error) {
3300
+ console.error('Coach observation generation after score upload failed:', error.message);
3301
+ result.coachObservations = {
3302
+ attempted: true,
3303
+ generated: 0,
3304
+ persisted: 0,
3305
+ skippedReason: 'generation_failed',
3306
+ sourceTrigger
3307
+ };
3308
+ }
3309
+ }
2909
3310
  json(response, 200, result);
2910
3311
  return;
2911
3312
  } catch (error) {
@@ -2915,6 +3316,13 @@ export function createSyncServiceRequestHandler({
2915
3316
  }
2916
3317
 
2917
3318
  if (request.method === 'GET') {
3319
+ const account = readAuthenticator
3320
+ ? await readAuthenticator(requestToken)
3321
+ : null;
3322
+ if (!account) {
3323
+ unauthorized(response, request);
3324
+ return;
3325
+ }
2918
3326
  if (!listScoreSnapshotsForAccount) {
2919
3327
  methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
2920
3328
  return;
@@ -2985,6 +3393,19 @@ export function createSyncServiceRequestHandler({
2985
3393
  return;
2986
3394
  }
2987
3395
 
3396
+ // Account deletion is an irreversible human action. Agent tokens — even
3397
+ // write-scoped ones — must never be able to delete the account; a leaked
3398
+ // CI/automation token would otherwise nuke everything. Require a human
3399
+ // (full/session) credential.
3400
+ if (typeof deleteAccount.tokenScope === 'string' && deleteAccount.tokenScope.startsWith('agent')) {
3401
+ logRequest(request, 403);
3402
+ json(response, 403, {
3403
+ error: 'Account deletion requires a human login and cannot be performed with an agent token.',
3404
+ code: 'AGENT_TOKEN_FORBIDDEN'
3405
+ });
3406
+ return;
3407
+ }
3408
+
2988
3409
  try {
2989
3410
  await deleteAccountForUser(deleteAccount.id);
2990
3411
  logRequest(request, 200);
@@ -3009,6 +3430,9 @@ export function createSyncServiceRequestHandler({
3009
3430
  ? { id: 'remote-user', email: null }
3010
3431
  : null;
3011
3432
  if (!proposalAccount) {
3433
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3434
+ return;
3435
+ }
3012
3436
  unauthorized(response, request);
3013
3437
  return;
3014
3438
  }
@@ -3077,6 +3501,9 @@ export function createSyncServiceRequestHandler({
3077
3501
  ? { id: 'remote-user', email: null }
3078
3502
  : null;
3079
3503
  if (!proposalAccount) {
3504
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3505
+ return;
3506
+ }
3080
3507
  unauthorized(response, request);
3081
3508
  return;
3082
3509
  }
@@ -3115,6 +3542,9 @@ export function createSyncServiceRequestHandler({
3115
3542
  ? await connectedWriteAuthenticator(requestToken)
3116
3543
  : null;
3117
3544
  if (!account) {
3545
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3546
+ return;
3547
+ }
3118
3548
  unauthorized(response, request);
3119
3549
  return;
3120
3550
  }
@@ -3199,6 +3629,9 @@ export function createSyncServiceRequestHandler({
3199
3629
  ? await connectedWriteAuthenticator(requestToken)
3200
3630
  : null;
3201
3631
  if (!account) {
3632
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3633
+ return;
3634
+ }
3202
3635
  unauthorized(response, request);
3203
3636
  return;
3204
3637
  }
@@ -3240,7 +3673,8 @@ export function createSyncServiceRequestHandler({
3240
3673
  tokenBootstrap: Boolean(issueSession || token),
3241
3674
  deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
3242
3675
  browserApproval: Boolean(approveDeviceChallenge),
3243
- devEmail: Boolean(issueDevLogin)
3676
+ devEmail: Boolean(issueDevLogin),
3677
+ agentTokens: Boolean(issueAgentToken && listAgentTokens && revokeAgentToken)
3244
3678
  }
3245
3679
  }));
3246
3680
  return;
@@ -3549,16 +3983,65 @@ export function createSyncServiceRequestHandler({
3549
3983
  methodNotAllowed(response, 'Use GET for /cli/coach-observations/current.');
3550
3984
  return;
3551
3985
  }
3986
+ if (route.options.refresh && route.options.refresh !== 'morning_open') {
3987
+ badRequest(response, 'Unsupported coach observations refresh value.');
3988
+ return;
3989
+ }
3552
3990
  if (!listCurrentCoachObservationsForAccount) {
3553
3991
  json(response, 503, { error: 'Coach observations not available' });
3554
3992
  return;
3555
3993
  }
3556
3994
  try {
3557
3995
  const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
3996
+ let refresh = null;
3997
+ if (route.options.refresh === 'morning_open') {
3998
+ const writeAccount = writeAuthenticator
3999
+ ? await writeAuthenticator(requestToken)
4000
+ : requestToken === token
4001
+ ? account
4002
+ : null;
4003
+ if (!writeAccount) {
4004
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4005
+ return;
4006
+ }
4007
+ unauthorized(response, request);
4008
+ return;
4009
+ }
4010
+ const sourceTrigger = 'daily_refresh';
4011
+ if (generateCoachObservationsForAccount) {
4012
+ try {
4013
+ const generationResult = await generateCoachObservationsForAccount(writeAccount, {
4014
+ sourceTrigger,
4015
+ refresh: route.options.refresh
4016
+ });
4017
+ refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
4018
+ } catch (error) {
4019
+ console.error('Coach observation generation on morning open failed:', error.message);
4020
+ refresh = {
4021
+ attempted: true,
4022
+ generated: 0,
4023
+ persisted: 0,
4024
+ skippedReason: 'generation_failed',
4025
+ sourceTrigger
4026
+ };
4027
+ }
4028
+ } else {
4029
+ refresh = {
4030
+ attempted: false,
4031
+ generated: 0,
4032
+ persisted: 0,
4033
+ skippedReason: 'generation_unavailable',
4034
+ sourceTrigger
4035
+ };
4036
+ }
4037
+ }
3558
4038
  const observations = await listCurrentCoachObservationsForAccount(account, {
3559
4039
  limit
3560
4040
  });
3561
- json(response, 200, { observations: observations ?? [] });
4041
+ json(response, 200, {
4042
+ observations: observations ?? [],
4043
+ ...(refresh ? { refresh } : {})
4044
+ });
3562
4045
  } catch (err) {
3563
4046
  console.error('Coach observations read error:', err.message);
3564
4047
  json(response, 500, { error: 'Failed to read coach observations' });
@@ -3575,6 +4058,18 @@ export function createSyncServiceRequestHandler({
3575
4058
  json(response, 503, { error: 'Coach observations not available' });
3576
4059
  return;
3577
4060
  }
4061
+ const writeAccount = connectedWriteAuthenticator
4062
+ ? await connectedWriteAuthenticator(requestToken)
4063
+ : requestToken === token
4064
+ ? account
4065
+ : null;
4066
+ if (!writeAccount) {
4067
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4068
+ return;
4069
+ }
4070
+ unauthorized(response, request);
4071
+ return;
4072
+ }
3578
4073
  let body = {};
3579
4074
  try {
3580
4075
  body = await readOptionalJsonBody(request);
@@ -3588,7 +4083,7 @@ export function createSyncServiceRequestHandler({
3588
4083
  return;
3589
4084
  }
3590
4085
  try {
3591
- const observation = await markCoachObservationSeenForAccount(account, route.options.id, { seenAt });
4086
+ const observation = await markCoachObservationSeenForAccount(writeAccount, route.options.id, { seenAt });
3592
4087
  if (!observation) {
3593
4088
  notFound(response, 'Observation not found');
3594
4089
  return;
@@ -3610,6 +4105,18 @@ export function createSyncServiceRequestHandler({
3610
4105
  json(response, 503, { error: 'Coach observations not available' });
3611
4106
  return;
3612
4107
  }
4108
+ const writeAccount = connectedWriteAuthenticator
4109
+ ? await connectedWriteAuthenticator(requestToken)
4110
+ : requestToken === token
4111
+ ? account
4112
+ : null;
4113
+ if (!writeAccount) {
4114
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4115
+ return;
4116
+ }
4117
+ unauthorized(response, request);
4118
+ return;
4119
+ }
3613
4120
  try {
3614
4121
  await readOptionalJsonBody(request);
3615
4122
  } catch {
@@ -3617,7 +4124,7 @@ export function createSyncServiceRequestHandler({
3617
4124
  return;
3618
4125
  }
3619
4126
  try {
3620
- const observation = await dismissCoachObservationForAccount(account, route.options.id, {});
4127
+ const observation = await dismissCoachObservationForAccount(writeAccount, route.options.id, {});
3621
4128
  if (!observation) {
3622
4129
  notFound(response, 'Observation not found');
3623
4130
  return;
@@ -3630,6 +4137,126 @@ export function createSyncServiceRequestHandler({
3630
4137
  return;
3631
4138
  }
3632
4139
 
4140
+ if (route.command === 'coach-observations-outcome') {
4141
+ if (request.method !== 'POST') {
4142
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/outcome.');
4143
+ return;
4144
+ }
4145
+ if (!recordCoachObservationOutcomeForAccount) {
4146
+ json(response, 503, { error: 'Coach observations not available' });
4147
+ return;
4148
+ }
4149
+ const writeAccount = connectedWriteAuthenticator
4150
+ ? await connectedWriteAuthenticator(requestToken)
4151
+ : requestToken === token
4152
+ ? account
4153
+ : null;
4154
+ if (!writeAccount) {
4155
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4156
+ return;
4157
+ }
4158
+ unauthorized(response, request);
4159
+ return;
4160
+ }
4161
+ let body = {};
4162
+ try {
4163
+ body = await readOptionalJsonBody(request);
4164
+ } catch {
4165
+ badRequest(response, 'Invalid request body.');
4166
+ return;
4167
+ }
4168
+ const outcomeStatus = String(body?.outcomeStatus ?? '').trim();
4169
+ if (!COACH_OBSERVATION_OUTCOME_STATUSES.has(outcomeStatus)) {
4170
+ badRequest(response, 'outcomeStatus must be improved, unchanged, regressed, or inconclusive.');
4171
+ return;
4172
+ }
4173
+ const outcomeObservedAt = body?.outcomeObservedAt ? new Date(body.outcomeObservedAt) : new Date();
4174
+ if (Number.isNaN(outcomeObservedAt.getTime())) {
4175
+ badRequest(response, 'Invalid outcomeObservedAt.');
4176
+ return;
4177
+ }
4178
+ try {
4179
+ const observation = await recordCoachObservationOutcomeForAccount(writeAccount, route.options.id, {
4180
+ outcomeStatus,
4181
+ outcomeObservedAt,
4182
+ outcomeNotes: typeof body?.outcomeNotes === 'string' && body.outcomeNotes.trim()
4183
+ ? body.outcomeNotes.trim()
4184
+ : null,
4185
+ linkedFollowupObservationId: typeof body?.linkedFollowupObservationId === 'string' && body.linkedFollowupObservationId.trim()
4186
+ ? body.linkedFollowupObservationId.trim()
4187
+ : null
4188
+ });
4189
+ if (!observation) {
4190
+ notFound(response, 'Observation not found');
4191
+ return;
4192
+ }
4193
+ json(response, 200, { observation });
4194
+ } catch (err) {
4195
+ console.error('Coach observation outcome error:', err.message);
4196
+ if (err.code === 'INVALID_LINKED_FOLLOWUP_OBSERVATION') {
4197
+ badRequest(response, err.message);
4198
+ return;
4199
+ }
4200
+ json(response, 500, { error: 'Failed to record observation outcome' });
4201
+ }
4202
+ return;
4203
+ }
4204
+
4205
+ if (route.command === 'coach-observations-feedback') {
4206
+ if (request.method !== 'POST') {
4207
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/feedback.');
4208
+ return;
4209
+ }
4210
+ if (!recordCoachObservationFeedbackForAccount) {
4211
+ json(response, 503, { error: 'Coach observations not available' });
4212
+ return;
4213
+ }
4214
+ const writeAccount = connectedWriteAuthenticator
4215
+ ? await connectedWriteAuthenticator(requestToken)
4216
+ : requestToken === token
4217
+ ? account
4218
+ : null;
4219
+ if (!writeAccount) {
4220
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4221
+ return;
4222
+ }
4223
+ unauthorized(response, request);
4224
+ return;
4225
+ }
4226
+ let body = {};
4227
+ try {
4228
+ body = await readOptionalJsonBody(request);
4229
+ } catch {
4230
+ badRequest(response, 'Invalid request body.');
4231
+ return;
4232
+ }
4233
+ const feedbackStatus = String(body?.feedbackStatus ?? '').trim();
4234
+ if (!COACH_OBSERVATION_FEEDBACK_STATUSES.has(feedbackStatus)) {
4235
+ badRequest(response, 'feedbackStatus must be accepted or rejected.');
4236
+ return;
4237
+ }
4238
+ const feedbackAt = body?.feedbackAt ? new Date(body.feedbackAt) : new Date();
4239
+ if (Number.isNaN(feedbackAt.getTime())) {
4240
+ badRequest(response, 'Invalid feedbackAt.');
4241
+ return;
4242
+ }
4243
+ try {
4244
+ const observation = await recordCoachObservationFeedbackForAccount(writeAccount, route.options.id, {
4245
+ feedbackStatus,
4246
+ feedbackAt
4247
+ });
4248
+ if (!observation) {
4249
+ notFound(response, 'Observation not found');
4250
+ return;
4251
+ }
4252
+ json(response, 200, { observation });
4253
+ } catch (err) {
4254
+ console.error('Coach observation feedback error:', err.message);
4255
+ json(response, 500, { error: 'Failed to record observation feedback' });
4256
+ }
4257
+ return;
4258
+ }
4259
+
3633
4260
  let snapshot;
3634
4261
  try {
3635
4262
  snapshot = loadSnapshotForAccount
@@ -3652,6 +4279,85 @@ export function createSyncServiceRequestHandler({
3652
4279
  };
3653
4280
  }
3654
4281
  };
4282
+ const weeklyCheckinContextForRow = async (row) => {
4283
+ const { weeklyCheckinContext } = await import('./queries.js');
4284
+ return weeklyCheckinContext(snapshot, account.id, weeklyCheckinContextOptions(row));
4285
+ };
4286
+ const priorWeeklyCheckinCommitment = async (ctx) => {
4287
+ if (!listActiveCoachCommitmentsForAccount || !ctx) return null;
4288
+ try {
4289
+ const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
4290
+ limit: 1,
4291
+ weekStartDate: ctx.weekRangeIso?.start
4292
+ });
4293
+ return activeCommitments[0] ?? null;
4294
+ } catch (commitmentErr) {
4295
+ console.error('Weekly-checkin commitment read failed:', commitmentErr.message);
4296
+ return null;
4297
+ }
4298
+ };
4299
+ const weeklyCheckinFreshnessForRow = async (row) => {
4300
+ const checkedAt = new Date().toISOString();
4301
+ if (!row?.recap || row.recap.placeholder) return null;
4302
+ const storedDigest = typeof row.recap.contextDigest === 'string' ? row.recap.contextDigest : null;
4303
+ if (!storedDigest) return { state: 'unknown', checkedAt };
4304
+ try {
4305
+ const ctx = await weeklyCheckinContextForRow(row);
4306
+ const priorCommitmentRow = await priorWeeklyCheckinCommitment(ctx);
4307
+ const { weeklyCheckinContextDigest } = await import('./queries.js');
4308
+ const currentDigest = weeklyCheckinContextDigest(ctx, {
4309
+ priorCommitment: priorCommitmentRow?.commitment ?? null,
4310
+ coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
4311
+ });
4312
+ if (!currentDigest) return { state: 'unknown', checkedAt };
4313
+ return { state: currentDigest === storedDigest ? 'fresh' : 'stale', checkedAt };
4314
+ } catch {
4315
+ return { state: 'unknown', checkedAt };
4316
+ }
4317
+ };
4318
+ const generateWeeklyCheckinRecapPayload = async (row) => {
4319
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
4320
+ if (!openrouterKey || !generateWeeklyCheckinRecapImpl || !generateCheckinQuestionsImpl) {
4321
+ const err = new Error('Weekly check-in refresh is unavailable.');
4322
+ err.code = 'weekly_checkin_refresh_unavailable';
4323
+ throw err;
4324
+ }
4325
+
4326
+ const ctx = await weeklyCheckinContextForRow(row);
4327
+ if (!ctx) {
4328
+ const err = new Error('Weekly check-in context is unavailable.');
4329
+ err.code = 'weekly_checkin_context_unavailable';
4330
+ throw err;
4331
+ }
4332
+
4333
+ const priorCommitmentRow = await priorWeeklyCheckinCommitment(ctx);
4334
+ const { weeklyCheckinContextDigest } = await import('./queries.js');
4335
+ const coachCommitmentIds = priorCommitmentRow?.id ? [priorCommitmentRow.id] : [];
4336
+ const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
4337
+ apiKey: openrouterKey,
4338
+ user: aiUser,
4339
+ sessionId: `weekly-checkin:${row.id}:recap`,
4340
+ priorCommitment: priorCommitmentRow?.commitment ?? null,
4341
+ contextMetadata: {
4342
+ coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
4343
+ }
4344
+ });
4345
+ const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
4346
+ apiKey: openrouterKey,
4347
+ user: aiUser,
4348
+ sessionId: `weekly-checkin:${row.id}:questions`
4349
+ });
4350
+ return {
4351
+ recapText: recapResult.text,
4352
+ questions: questionsResult.questions,
4353
+ model: recapResult.model,
4354
+ generatedAt: new Date().toISOString(),
4355
+ contextDigest: weeklyCheckinContextDigest(ctx, {
4356
+ priorCommitment: priorCommitmentRow?.commitment ?? null,
4357
+ coachCommitmentIds
4358
+ })
4359
+ };
4360
+ };
3655
4361
 
3656
4362
  if (route.command === 'increment-score-current') {
3657
4363
  if (request.method !== 'GET') {
@@ -3686,6 +4392,12 @@ export function createSyncServiceRequestHandler({
3686
4392
  if (route.options.toolName === 'get_increment_score') {
3687
4393
  await hydrateIncrementScore(body?.historyDays ?? 14);
3688
4394
  }
4395
+ const observationOptions = coachToolObservationOptions(route.options.toolName, body ?? {});
4396
+ if (observationOptions) {
4397
+ snapshot.coachObservations = listCurrentCoachObservationsForAccount
4398
+ ? await listCurrentCoachObservationsForAccount(account, observationOptions) ?? []
4399
+ : [];
4400
+ }
3689
4401
  json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
3690
4402
  return;
3691
4403
  } catch (error) {
@@ -3844,52 +4556,11 @@ export function createSyncServiceRequestHandler({
3844
4556
  }
3845
4557
  // Lazy-gen path: if scheduled and overdue, attempt to generate now.
3846
4558
  if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
3847
- const openrouterKey = process.env.OPENROUTER_API_KEY;
3848
4559
  let recap = null;
3849
- if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3850
- try {
3851
- const { weeklyCheckinContext } = await import('./queries.js');
3852
- // Anchor to row.weekStartDate so lazy-gen describes the
3853
- // canonical week, matching cron behaviour (onemore-8oc5).
3854
- const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
3855
- const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
3856
- if (ctx) {
3857
- let priorCommitmentRow = null;
3858
- if (listActiveCoachCommitmentsForAccount) {
3859
- try {
3860
- const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
3861
- limit: 1,
3862
- weekStartDate: ctx.weekRangeIso?.start
3863
- });
3864
- priorCommitmentRow = activeCommitments[0] ?? null;
3865
- } catch (commitmentErr) {
3866
- console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
3867
- }
3868
- }
3869
- const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
3870
- apiKey: openrouterKey,
3871
- user: aiUser,
3872
- sessionId: `weekly-checkin:${row.id}:recap`,
3873
- priorCommitment: priorCommitmentRow?.commitment ?? null,
3874
- contextMetadata: {
3875
- coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
3876
- }
3877
- });
3878
- const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
3879
- apiKey: openrouterKey,
3880
- user: aiUser,
3881
- sessionId: `weekly-checkin:${row.id}:questions`
3882
- });
3883
- recap = {
3884
- recapText: recapResult.text,
3885
- questions: questionsResult.questions,
3886
- model: recapResult.model,
3887
- generatedAt: new Date().toISOString()
3888
- };
3889
- }
3890
- } catch (genErr) {
3891
- console.error('Lazy weekly-checkin gen failed:', genErr.message);
3892
- }
4560
+ try {
4561
+ recap = await generateWeeklyCheckinRecapPayload(row);
4562
+ } catch (genErr) {
4563
+ console.error('Lazy weekly-checkin gen failed:', genErr.message);
3893
4564
  }
3894
4565
  if (!recap) {
3895
4566
  recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
@@ -3907,12 +4578,14 @@ export function createSyncServiceRequestHandler({
3907
4578
  }
3908
4579
  }
3909
4580
  }
4581
+ const freshness = await weeklyCheckinFreshnessForRow(row);
3910
4582
  json(response, 200, {
3911
4583
  id: row.id,
3912
4584
  weekStartDate: row.weekStartDate,
3913
4585
  status: row.status,
3914
4586
  recap: row.recap,
3915
- conversationId: row.conversationId
4587
+ conversationId: row.conversationId,
4588
+ freshness
3916
4589
  });
3917
4590
  } catch (err) {
3918
4591
  console.error('Weekly check-in current error:', err.message);
@@ -3969,7 +4642,10 @@ export function createSyncServiceRequestHandler({
3969
4642
  json(response, 503, { error: 'Weekly check-in not available' });
3970
4643
  return;
3971
4644
  }
3972
- const row = await getCurrentWeeklyCheckinForAccount(account);
4645
+ let body;
4646
+ try { body = await readOptionalJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
4647
+ const refreshIfStale = body?.refreshIfStale === true;
4648
+ let row = await getCurrentWeeklyCheckinForAccount(account);
3973
4649
  if (!row) {
3974
4650
  notFound(response, 'No weekly check-in scheduled.');
3975
4651
  return;
@@ -3979,9 +4655,9 @@ export function createSyncServiceRequestHandler({
3979
4655
  return;
3980
4656
  }
3981
4657
  const conversationId = `weekly-checkin:${row.id}`;
3982
- const recapText = String(row.recap.recapText ?? '').trim();
3983
- const questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
3984
- const firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
4658
+ let recapText = String(row.recap.recapText ?? '').trim();
4659
+ let questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
4660
+ let firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
3985
4661
  try {
3986
4662
  const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
3987
4663
  const existingConversation = getAskConversationForAccount
@@ -3997,6 +4673,61 @@ export function createSyncServiceRequestHandler({
3997
4673
  });
3998
4674
  return;
3999
4675
  }
4676
+ if (refreshIfStale) {
4677
+ const freshness = await weeklyCheckinFreshnessForRow(row);
4678
+ if (freshness?.state === 'stale' || freshness?.state === 'unknown') {
4679
+ if (!updateWeeklyCheckinRecapForAccount) {
4680
+ json(response, 503, {
4681
+ error: 'Weekly check-in refresh is not available.',
4682
+ code: 'refresh_unavailable'
4683
+ });
4684
+ return;
4685
+ }
4686
+ let recap;
4687
+ try {
4688
+ recap = await generateWeeklyCheckinRecapPayload(row);
4689
+ } catch (refreshErr) {
4690
+ console.error('Weekly check-in refresh failed:', refreshErr.message);
4691
+ if (refreshErr?.code === 'weekly_checkin_refresh_unavailable' ||
4692
+ refreshErr?.code === 'weekly_checkin_context_unavailable') {
4693
+ json(response, 503, {
4694
+ error: 'Weekly check-in refresh is not available.',
4695
+ code: 'refresh_unavailable'
4696
+ });
4697
+ } else {
4698
+ json(response, 502, {
4699
+ error: 'Failed to refresh weekly check-in.',
4700
+ code: 'refresh_failed'
4701
+ });
4702
+ }
4703
+ return;
4704
+ }
4705
+ let updated;
4706
+ try {
4707
+ updated = await updateWeeklyCheckinRecapForAccount(account, row.id, {
4708
+ recap
4709
+ });
4710
+ } catch (refreshUpdateErr) {
4711
+ console.error('Weekly check-in refresh update failed:', refreshUpdateErr.message);
4712
+ json(response, 502, {
4713
+ error: 'Failed to refresh weekly check-in.',
4714
+ code: 'refresh_failed'
4715
+ });
4716
+ return;
4717
+ }
4718
+ if (!updated) {
4719
+ json(response, 409, {
4720
+ error: 'Weekly check-in was not refreshed.',
4721
+ code: 'refresh_conflict'
4722
+ });
4723
+ return;
4724
+ }
4725
+ row = updated;
4726
+ }
4727
+ }
4728
+ recapText = String(row.recap.recapText ?? '').trim();
4729
+ questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
4730
+ firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
4000
4731
  if (row.status !== 'in_progress') {
4001
4732
  let openedRow = row;
4002
4733
  if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
@@ -4228,12 +4959,128 @@ export function createSyncServiceRequestHandler({
4228
4959
  return;
4229
4960
  }
4230
4961
 
4962
+ if (route.command === 'ask-plan') {
4963
+ if (request.method !== 'POST') {
4964
+ methodNotAllowed(response, 'Use POST for /cli/ask/plan.');
4965
+ return;
4966
+ }
4967
+
4968
+ let body;
4969
+ try {
4970
+ body = await readJsonBody(request);
4971
+ } catch {
4972
+ badRequest(response, 'Invalid JSON body.');
4973
+ return;
4974
+ }
4975
+
4976
+ const question = body?.question;
4977
+ if (!question || typeof question !== 'string' || question.trim().length === 0) {
4978
+ badRequest(response, 'question is required');
4979
+ return;
4980
+ }
4981
+ if (question.length > 500) {
4982
+ badRequest(response, 'question must be 500 characters or fewer');
4983
+ return;
4984
+ }
4985
+
4986
+ const exclude = parseExclude(body?.exclude);
4987
+ const conversationId = typeof body?.conversationId === 'string' ? body.conversationId : null;
4988
+ const history = sanitizeHistory(body?.history);
4989
+ const persistedConversation = conversationId && getAskConversationForAccount
4990
+ ? await getAskConversationForAccount(account, conversationId)
4991
+ : null;
4992
+ const persistedMessages = Array.isArray(persistedConversation?.messages)
4993
+ ? sanitizeHistory(persistedConversation.messages)
4994
+ : null;
4995
+ const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
4996
+ const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
4997
+ const plannedEvidence = planAskEvidence(snapshot, question, {
4998
+ exclude,
4999
+ history: canonicalHistory
5000
+ });
5001
+ let coachFacts = [];
5002
+ if (listCoachFactsForAccount) {
5003
+ try {
5004
+ const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
5005
+ coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
5006
+ } catch (factErr) {
5007
+ console.error('Coach facts read error (ask plan):', factErr.message);
5008
+ }
5009
+ }
5010
+ if (
5011
+ listScoreSnapshotsForAccount &&
5012
+ (requestedCoachObservation || plannedEvidence.requiredTools.includes('get_increment_score'))
5013
+ ) {
5014
+ try {
5015
+ const scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit: 14 }) ?? [];
5016
+ if (scoreSnapshots.length > 0) {
5017
+ const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
5018
+ snapshot.incrementScore = {
5019
+ latest: enrichedSnapshots[0],
5020
+ history: enrichedSnapshots
5021
+ };
5022
+ }
5023
+ } catch (scoreErr) {
5024
+ console.error('Increment Score read error (ask plan):', scoreErr.message);
5025
+ }
5026
+ }
5027
+ let coachObservations = [];
5028
+ if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
5029
+ try {
5030
+ coachObservations = await listCurrentCoachObservationsForAccount(account, {
5031
+ limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
5032
+ includeOutcomeHistory: true
5033
+ }) ?? [];
5034
+ } catch (observationErr) {
5035
+ console.error('Coach observations read error (ask plan):', observationErr.message);
5036
+ }
5037
+ }
5038
+ snapshot.coachObservations = coachObservations;
5039
+ const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
5040
+ const routedContext = coachObservationFollowUp
5041
+ ? askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, {
5042
+ exclude,
5043
+ coachFacts,
5044
+ intent: requestedCoachObservation?.intent
5045
+ })
5046
+ : requestedCoachObservation?.intent === 'successor_plan'
5047
+ ? askMissingObservationFollowUpContext(snapshot, question, requestedCoachObservation, {
5048
+ exclude,
5049
+ intent: requestedCoachObservation.intent,
5050
+ today: new Date()
5051
+ })
5052
+ : askRoutedContext(snapshot, question, {
5053
+ exclude,
5054
+ coachFacts,
5055
+ coachObservations,
5056
+ history: canonicalHistory
5057
+ });
5058
+ json(response, 200, {
5059
+ contextBundle: routedContext.contextBundle,
5060
+ metadata: routedContext.metadata
5061
+ });
5062
+ return;
5063
+ }
5064
+
4231
5065
  if (route.command === 'ask-ai') {
4232
5066
  if (request.method !== 'POST') {
4233
5067
  methodNotAllowed(response, 'Use POST for /cli/ask.');
4234
5068
  return;
4235
5069
  }
4236
5070
 
5071
+ const writeAccount = connectedWriteAuthenticator
5072
+ ? await connectedWriteAuthenticator(requestToken)
5073
+ : requestToken === token
5074
+ ? account
5075
+ : null;
5076
+ if (!writeAccount) {
5077
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
5078
+ return;
5079
+ }
5080
+ unauthorized(response, request);
5081
+ return;
5082
+ }
5083
+
4237
5084
  let body;
4238
5085
  try {
4239
5086
  body = await readJsonBody(request);
@@ -4266,20 +5113,23 @@ export function createSyncServiceRequestHandler({
4266
5113
  ? sanitizeHistory(persistedConversation.messages)
4267
5114
  : null;
4268
5115
  const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
5116
+ const persistedStorageMessages = Array.isArray(persistedConversation?.messages)
5117
+ ? sanitizeAskMessagesForStorage(persistedConversation.messages, { allowStructured: true })
5118
+ : null;
5119
+ const canonicalStorageHistory = (persistedStorageMessages?.length ? persistedStorageMessages : null)
5120
+ ?? sanitizeAskMessagesForStorage(body?.history);
4269
5121
  const openrouterKey = process.env.OPENROUTER_API_KEY;
4270
5122
  if (!openrouterKey) {
4271
5123
  json(response, 503, { error: 'AI not configured', code: 'not_configured' });
4272
5124
  return;
4273
5125
  }
4274
5126
 
4275
- const queries = await import('./queries.js');
4276
5127
  const exclude = parseExclude(body?.exclude);
5128
+ const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
4277
5129
  let coachFacts = [];
4278
5130
  if (listCoachFactsForAccount) {
4279
5131
  try {
4280
- const kinds = queries.coachFactKindsForAskQuestion
4281
- ? queries.coachFactKindsForAskQuestion(snapshot, question)
4282
- : [];
5132
+ const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
4283
5133
  coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
4284
5134
  } catch (factErr) {
4285
5135
  console.error('Coach facts read error (ask):', factErr.message);
@@ -4301,19 +5151,35 @@ export function createSyncServiceRequestHandler({
4301
5151
  };
4302
5152
  }
4303
5153
  let coachObservations = [];
4304
- if (listCurrentCoachObservationsForAccount) {
5154
+ if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
4305
5155
  try {
4306
- coachObservations = await listCurrentCoachObservationsForAccount(account, { limit: 3 }) ?? [];
5156
+ coachObservations = await listCurrentCoachObservationsForAccount(account, {
5157
+ limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
5158
+ includeOutcomeHistory: true
5159
+ }) ?? [];
4307
5160
  } catch (observationErr) {
4308
5161
  console.error('Coach observations read error (ask):', observationErr.message);
4309
5162
  }
4310
5163
  }
4311
-
4312
- const routedContext = queries.askRoutedContext
4313
- ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations })
4314
- : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
5164
+ snapshot.coachObservations = coachObservations;
5165
+ const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
5166
+ const missingRequestedCoachObservation = Boolean(requestedCoachObservation && !coachObservationFollowUp);
5167
+
5168
+ const routedContext = coachObservationFollowUp
5169
+ ? askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, {
5170
+ exclude,
5171
+ coachFacts,
5172
+ intent: requestedCoachObservation?.intent
5173
+ })
5174
+ : requestedCoachObservation?.intent === 'successor_plan'
5175
+ ? askMissingObservationFollowUpContext(snapshot, question, requestedCoachObservation, {
5176
+ exclude,
5177
+ intent: requestedCoachObservation.intent,
5178
+ today: new Date()
5179
+ })
5180
+ : askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations, history: canonicalHistory });
4315
5181
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4316
- const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
5182
+ const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots, { question });
4317
5183
 
4318
5184
  const preludes = [incrementScorePrelude].filter(Boolean);
4319
5185
  const ctx = preludes.length > 0
@@ -4323,51 +5189,195 @@ export function createSyncServiceRequestHandler({
4323
5189
  const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
4324
5190
 
4325
5191
  try {
4326
- const { AI_PROMPT_VERSIONS, generateAskAnswer, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
4327
- const generateAsk = generateAskAnswerImpl ?? generateAskAnswer;
5192
+ const { AI_PROMPT_VERSIONS, generateAskAnswerAgentic, SYSTEM_PROMPTS_FOR_LEAK_CHECK, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
5193
+ const generateAsk = generateAskAnswerImpl ?? generateAskAnswerAgentic;
4328
5194
  const systemPromptOverride = persistedKind === 'weekly-checkin' ? WEEKLY_CHECKIN_PROMPT : undefined;
5195
+ const routingMetadata = {
5196
+ ...routedContext.metadata,
5197
+ contextCharCount: ctx.length,
5198
+ historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
5199
+ coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
5200
+ coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
5201
+ coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
5202
+ coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? [],
5203
+ requestedCoachObservationId: requestedCoachObservation?.id ?? undefined,
5204
+ requestedCoachObservationIntent: requestedCoachObservation?.intent ?? undefined,
5205
+ coachObservationFollowUpMissing: missingRequestedCoachObservation || undefined
5206
+ };
4329
5207
 
4330
- const askResult = await generateAsk(ctx, question, {
4331
- apiKey: openrouterKey,
4332
- history: canonicalHistory,
4333
- tone: askTone,
4334
- user: aiUser,
4335
- sessionId: `ask:${conversationId}`,
4336
- systemPrompt: systemPromptOverride,
4337
- routingMetadata: {
4338
- ...routedContext.metadata,
4339
- contextCharCount: ctx.length,
4340
- historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4341
- coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4342
- coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4343
- coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
4344
- coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
4345
- }
4346
- });
5208
+ const generateAttempt = async (contextForGeneration, { maxSteps } = {}) => {
5209
+ const result = await generateAsk(contextForGeneration, question, {
5210
+ apiKey: openrouterKey,
5211
+ history: canonicalHistory,
5212
+ tone: askTone,
5213
+ user: aiUser,
5214
+ sessionId: `ask:${conversationId}`,
5215
+ systemPrompt: systemPromptOverride,
5216
+ routingMetadata,
5217
+ snapshot,
5218
+ exclude: [...exclude],
5219
+ ...(Number.isInteger(maxSteps) ? { maxSteps } : {})
5220
+ });
4347
5221
 
4348
- const parsedAsk = extractAskProgramDraft(askResult.text, {
4349
- canonicalizeExerciseName: queries.canonicalExerciseName
4350
- });
4351
- const assistantAnswer = stripXMLTagBlocks(parsedAsk.answerText);
5222
+ // Fold any tools the agent fetched dynamically into provenance so
5223
+ // structured evidence and verifier replay reflect what was actually
5224
+ // read, not just the warm-start route plan.
5225
+ mergeAgenticToolProvenance(routingMetadata, result.toolInvocations);
5226
+
5227
+ const parsed = extractAskProgramDraft(result.text, {
5228
+ canonicalizeExerciseName: canonicalExerciseName
5229
+ });
5230
+ const changesetParsed = extractPlanChangeset(parsed.answerText);
5231
+ const requestedPlanAdjustment = requestedCoachObservation?.intent === 'plan_adjustment';
5232
+ const draft = missingRequestedCoachObservation && requestedCoachObservation?.intent === 'successor_plan'
5233
+ ? undefined
5234
+ : parsed.programDraft;
5235
+ // Drop a changeset if the requested observation is missing/stale — same
5236
+ // guard as successor_plan: no live observation, no plan edits.
5237
+ const changeset = !requestedPlanAdjustment || missingRequestedCoachObservation
5238
+ ? undefined
5239
+ : changesetParsed.planChangeset;
5240
+ const answer = stripXMLTagBlocks(changesetParsed.answerText);
5241
+ return { askResult: result, programDraft: draft, planChangeset: changeset, assistantAnswer: answer };
5242
+ };
5243
+
5244
+ let attempt = await generateAttempt(ctx);
5245
+ let programDraftRetryCount = 0;
5246
+ let planChangesetRetryCount = 0;
5247
+ if (
5248
+ shouldRequireProgramDraftForAsk({
5249
+ persistedKind,
5250
+ requestedCoachObservation,
5251
+ missingRequestedCoachObservation
5252
+ }) &&
5253
+ !attempt.programDraft
5254
+ ) {
5255
+ programDraftRetryCount = 1;
5256
+ const draftRepairContext = buildMissingProgramDraftRepairContext(ctx);
5257
+ const draftRepairAttempt = await generateAttempt(draftRepairContext, { maxSteps: 2 });
5258
+ if (draftRepairAttempt.programDraft || !attempt.assistantAnswer) {
5259
+ attempt = draftRepairAttempt;
5260
+ }
5261
+ }
5262
+ if (
5263
+ shouldRequirePlanChangesetForAsk({
5264
+ persistedKind,
5265
+ requestedCoachObservation,
5266
+ missingRequestedCoachObservation
5267
+ }) &&
5268
+ !attempt.planChangeset
5269
+ ) {
5270
+ planChangesetRetryCount = 1;
5271
+ const changesetRepairContext = buildMissingPlanChangesetRepairContext(ctx);
5272
+ const changesetRepairAttempt = await generateAttempt(changesetRepairContext, { maxSteps: 2 });
5273
+ if (changesetRepairAttempt.planChangeset || !attempt.assistantAnswer) {
5274
+ attempt = changesetRepairAttempt;
5275
+ }
5276
+ }
4352
5277
  // Check for system prompt leakage before persisting. We inspect only
4353
5278
  // the user-visible prose, not the structured draft payload, so valid
4354
5279
  // <program_draft> output does not false-positive as a prompt leak.
4355
- const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
4356
- if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
5280
+ if (detectSystemPromptLeak(attempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
4357
5281
  console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
4358
5282
  onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
4359
- json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
5283
+ json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: attempt.askResult.model, filtered: true });
4360
5284
  return;
4361
5285
  }
4362
5286
 
4363
- const promptSurface = persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask';
4364
- const promptVersion = persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.ask;
4365
- const metadata = buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, askResult);
4366
- const updatedMessages = [
4367
- ...canonicalHistory,
5287
+ let verification = persistedKind === 'ask'
5288
+ ? verifyAskAnswer({
5289
+ answer: attempt.assistantAnswer,
5290
+ snapshot,
5291
+ routingMetadata,
5292
+ today: new Date(),
5293
+ exclude: [...exclude]
5294
+ })
5295
+ : null;
5296
+ let verificationRetryCount = 0;
5297
+ let verificationRepaired = false;
5298
+ let verificationFallback = false;
5299
+
5300
+ if (persistedKind === 'ask' && shouldRepairAskAnswer(verification)) {
5301
+ verificationRetryCount = 1;
5302
+ const repairContext = buildAskAnswerRepairContext(ctx, attempt.assistantAnswer, verification);
5303
+ const repairAttempt = await generateAttempt(repairContext, { maxSteps: 2 });
5304
+ if (detectSystemPromptLeak(repairAttempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
5305
+ console.error('SECURITY: System prompt leak detected in ask-ai repair response, blocking');
5306
+ onError?.(new Error('System prompt leak detected in AI repair response'), { feature: 'ask-coach', security: true });
5307
+ json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: repairAttempt.askResult.model, filtered: true });
5308
+ return;
5309
+ }
5310
+ const repairVerification = verifyAskAnswer({
5311
+ answer: repairAttempt.assistantAnswer,
5312
+ snapshot,
5313
+ routingMetadata,
5314
+ today: new Date(),
5315
+ exclude: [...exclude]
5316
+ });
5317
+ if (repairVerification.blockingFailureCount <= verification.blockingFailureCount) {
5318
+ attempt = repairAttempt;
5319
+ verification = repairVerification;
5320
+ verificationRepaired = repairVerification.passed;
5321
+ }
5322
+ }
5323
+
5324
+ if (persistedKind === 'ask' && shouldRepairAskAnswer(verification)) {
5325
+ verificationFallback = true;
5326
+ attempt = {
5327
+ ...attempt,
5328
+ assistantAnswer: safeAskVerificationFallback(),
5329
+ programDraft: undefined,
5330
+ planChangeset: undefined
5331
+ };
5332
+ }
5333
+
5334
+ const answerVerification = persistedKind === 'ask'
5335
+ ? askVerificationMetadata(verification, {
5336
+ retryCount: verificationRetryCount,
5337
+ repaired: verificationRepaired,
5338
+ fallback: verificationFallback
5339
+ })
5340
+ : undefined;
5341
+ if (answerVerification) {
5342
+ routingMetadata.answerVerification = answerVerification;
5343
+ }
5344
+ if (verificationFallback && onError) {
5345
+ const warning = new Error('Ask Coach answer verification fallback');
5346
+ warning.level = 'warning';
5347
+ onError(warning, {
5348
+ feature: 'ask-coach',
5349
+ verification: answerVerification
5350
+ });
5351
+ }
5352
+
5353
+ const { askResult, assistantAnswer, programDraft, planChangeset } = attempt;
5354
+ const promptSurface = askResult.promptSurface
5355
+ ?? (persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask');
5356
+ const promptVersion = askResult.promptVersion
5357
+ ?? (persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.askAgentic);
5358
+ const metadata = {
5359
+ ...buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, {
5360
+ ...askResult,
5361
+ routingMetadata
5362
+ }),
5363
+ routing: routingMetadata,
5364
+ ...(programDraftRetryCount > 0 ? { programDraftRetryCount } : {}),
5365
+ ...(planChangesetRetryCount > 0 ? { planChangesetRetryCount } : {})
5366
+ };
5367
+ const structured = buildAskStructuredResponse(assistantAnswer, routingMetadata, { programDraft, planChangeset, question });
5368
+ console.log(`ask-coach-meta ${JSON.stringify(buildAskInteractionLogPayload({
5369
+ accountId: account.id,
5370
+ status: 'ok',
5371
+ promptSurface,
5372
+ routingMetadata,
5373
+ askResult,
5374
+ structured
5375
+ }))}`);
5376
+ const updatedMessages = sanitizeAskMessagesForStorage([
5377
+ ...canonicalStorageHistory,
4368
5378
  { role: 'user', content: question },
4369
- { role: 'assistant', content: assistantAnswer }
4370
- ];
5379
+ { role: 'assistant', content: assistantAnswer, structured }
5380
+ ], { allowStructured: true });
4371
5381
  if (saveAskConversationForAccount) {
4372
5382
  try {
4373
5383
  await saveAskConversationForAccount(account, {
@@ -4455,7 +5465,9 @@ export function createSyncServiceRequestHandler({
4455
5465
  answer: assistantAnswer,
4456
5466
  model: askResult.model,
4457
5467
  metadata,
4458
- programDraft: parsedAsk.programDraft
5468
+ structured,
5469
+ programDraft,
5470
+ planChangeset
4459
5471
  });
4460
5472
  } catch (err) {
4461
5473
  console.error('AI ask error:', err.message);
@@ -4703,6 +5715,10 @@ export function createSyncServiceRequestHandler({
4703
5715
  return;
4704
5716
  }
4705
5717
  const report = await social.getUserReport(account.id, route.options.accountId);
5718
+ if (report?.error === 'forbidden') {
5719
+ json(response, 403, { ok: false, error: 'Access denied' });
5720
+ return;
5721
+ }
4706
5722
  if (!report) {
4707
5723
  notFound(response, 'User report not found.');
4708
5724
  return;