incremnt 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,24 @@ 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
+
693
897
  function coachObservationSourceTriggerForScoreSnapshots(snapshots) {
694
898
  if (!Array.isArray(snapshots)) return undefined;
695
899
  const reasons = snapshots.map((snapshot) => snapshot?.triggerReason);
@@ -717,7 +921,12 @@ function coachObservationGenerationMetadata(result, sourceTrigger) {
717
921
  function normalizeAskCoachObservationFollowUp(value) {
718
922
  if (!value || typeof value !== 'object') return null;
719
923
  const id = String(value.id ?? '').trim();
720
- return id ? { id: id.slice(0, 128) } : null;
924
+ if (!id) return null;
925
+ const intent = normalizeObservationFollowUpIntent(value.intent);
926
+ return {
927
+ id: id.slice(0, 128),
928
+ ...(intent ? { intent } : {})
929
+ };
721
930
  }
722
931
 
723
932
  function selectAskCoachObservationFollowUp(requested, observations) {
@@ -726,6 +935,8 @@ function selectAskCoachObservationFollowUp(requested, observations) {
726
935
  .find((observation) => String(observation?.id ?? '') === requested.id);
727
936
  }
728
937
 
938
+ const ASK_REQUESTED_COACH_OBSERVATION_LIMIT = 20;
939
+
729
940
  function routeRequest(url, method) {
730
941
  const pathname = url.pathname;
731
942
 
@@ -797,6 +1008,22 @@ function routeRequest(url, method) {
797
1008
  return { command: 'contract', options: {} };
798
1009
  }
799
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
+
800
1027
  if (pathname === '/sync/snapshot') {
801
1028
  return { command: 'sync-upload', options: {} };
802
1029
  }
@@ -944,11 +1171,34 @@ function routeRequest(url, method) {
944
1171
  return { command: 'program-detail', options: {} };
945
1172
  }
946
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
+
947
1185
  const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
948
1186
  if (programShowMatch) {
949
1187
  return { command: 'program-detail', options: { id: programShowMatch[1] } };
950
1188
  }
951
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
+
952
1202
  if (pathname === '/cli/exercises/history') {
953
1203
  return {
954
1204
  command: 'exercise-history',
@@ -980,6 +1230,16 @@ function routeRequest(url, method) {
980
1230
  };
981
1231
  }
982
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
+
983
1243
  const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
984
1244
  if (cyclesShowMatch) {
985
1245
  return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
@@ -1064,6 +1324,10 @@ function routeRequest(url, method) {
1064
1324
  return { command: 'ask-ai', options: {} };
1065
1325
  }
1066
1326
 
1327
+ if (pathname === '/cli/ask/plan') {
1328
+ return { command: 'ask-plan', options: {} };
1329
+ }
1330
+
1067
1331
  if (pathname === '/cli/ask/history') {
1068
1332
  return { command: 'ask-history', options: { limit: url.searchParams.get('limit') ?? undefined } };
1069
1333
  }
@@ -1110,7 +1374,7 @@ function routeRequest(url, method) {
1110
1374
  }
1111
1375
 
1112
1376
  {
1113
- const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
1377
+ const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss|outcome|feedback)$/);
1114
1378
  if (coachObservationActionMatch) {
1115
1379
  return {
1116
1380
  command: `coach-observations-${coachObservationActionMatch[2]}`,
@@ -1141,6 +1405,15 @@ function routeRequest(url, method) {
1141
1405
  return { command: 'training-load', options: {} };
1142
1406
  }
1143
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
+
1144
1417
  // --- Social endpoints ---
1145
1418
  if (pathname === '/cli/social/me/profile') {
1146
1419
  return { command: 'social-me-profile', options: {} };
@@ -1505,55 +1778,10 @@ function routeRequest(url, method) {
1505
1778
  return null;
1506
1779
  }
1507
1780
 
1508
- export function formatIncrementScorePrelude(snapshots) {
1509
- if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1510
- const latest = snapshots[0];
1511
- if (latest == null || typeof latest.score !== 'number') return null;
1512
-
1513
- const lines = ['[Increment Score]'];
1514
- const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
1515
- lines.push(`- Current: ${latest.score}/100${tier}`);
1516
-
1517
- if (latest.components && typeof latest.components === 'object') {
1518
- const parts = [];
1519
- for (const [name, value] of Object.entries(latest.components)) {
1520
- const num = typeof value === 'number' ? value : value?.score;
1521
- if (typeof num === 'number') parts.push(`${name} ${num}`);
1522
- }
1523
- if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
1524
- }
1525
-
1526
- const driverLabels = (list) => {
1527
- if (!Array.isArray(list) || list.length === 0) return null;
1528
- return list
1529
- .slice(0, 3)
1530
- .map((d) => d?.label ?? d?.id ?? d?.driver)
1531
- .filter(Boolean)
1532
- .join('; ');
1533
- };
1534
- const positives = driverLabels(latest.topPositiveDrivers);
1535
- if (positives) lines.push(`- Top positive drivers: ${positives}`);
1536
- const negatives = driverLabels(latest.topNegativeDrivers);
1537
- if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
1538
-
1539
- if (snapshots.length > 1) {
1540
- const prior = snapshots[1];
1541
- if (typeof prior?.score === 'number') {
1542
- const delta = latest.score - prior.score;
1543
- const sign = delta > 0 ? '+' : '';
1544
- lines.push(`- Day-over-day delta: ${sign}${delta}`);
1545
- }
1546
- const recent = snapshots
1547
- .slice(0, 7)
1548
- .map((s) => (typeof s?.score === 'number' ? s.score : null))
1549
- .filter((s) => s != null);
1550
- if (recent.length >= 3) {
1551
- lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
1552
- }
1553
- }
1554
-
1555
- return lines.join('\n');
1556
- }
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 };
1557
1785
 
1558
1786
  async function readJsonBody(request) {
1559
1787
  const chunks = [];
@@ -1979,7 +2207,8 @@ export function syncServiceContractPayload({
1979
2207
  tokenBootstrap: true,
1980
2208
  deviceFlow: false,
1981
2209
  browserApproval: false,
1982
- devEmail: false
2210
+ devEmail: false,
2211
+ agentTokens: false
1983
2212
  },
1984
2213
  providers = {
1985
2214
  apple: {
@@ -2042,6 +2271,10 @@ export function createSyncServiceRequestHandler({
2042
2271
  completeGoogleWebAuth = null,
2043
2272
  completeGoogleMobileAuth = null,
2044
2273
  refreshSession,
2274
+ issueAgentToken,
2275
+ listAgentTokens,
2276
+ revokeAgentToken,
2277
+ authenticateAgentTokenManagement,
2045
2278
  authenticateConnectedWriteToken,
2046
2279
  allowManualDeviceApproval = false,
2047
2280
  rateLimitConfig = null,
@@ -2067,6 +2300,7 @@ export function createSyncServiceRequestHandler({
2067
2300
  getCurrentWeeklyCheckinForAccount = null,
2068
2301
  upsertScheduledWeeklyCheckinForAccount = null,
2069
2302
  transitionWeeklyCheckinForAccount = null,
2303
+ updateWeeklyCheckinRecapForAccount = null,
2070
2304
  generateWeeklyCheckinRecapImpl = null,
2071
2305
  generateCheckinQuestionsImpl = null,
2072
2306
  saveAIFeedbackForAccount = null,
@@ -2083,10 +2317,21 @@ export function createSyncServiceRequestHandler({
2083
2317
  listCurrentCoachObservationsForAccount = null,
2084
2318
  markCoachObservationSeenForAccount = null,
2085
2319
  dismissCoachObservationForAccount = null,
2320
+ recordCoachObservationOutcomeForAccount = null,
2321
+ recordCoachObservationFeedbackForAccount = null,
2086
2322
  // Social
2087
2323
  social = null,
2088
2324
  onError = null
2089
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
+
2090
2335
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
2091
2336
 
2092
2337
  return async function handle(request, response) {
@@ -2883,6 +3128,101 @@ export function createSyncServiceRequestHandler({
2883
3128
  const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
2884
3129
  const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
2885
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;
3136
+
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.');
3140
+ return;
3141
+ }
3142
+
3143
+ if (!requestToken) {
3144
+ unauthorized(response, request);
3145
+ return;
3146
+ }
3147
+
3148
+ const managementAccount = agentTokenManagementAuthenticator
3149
+ ? await agentTokenManagementAuthenticator(requestToken)
3150
+ : null;
3151
+ if (!managementAccount) {
3152
+ unauthorized(response, request);
3153
+ return;
3154
+ }
3155
+
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
+ });
3187
+ return;
3188
+ }
3189
+
3190
+ if (route.command === 'agent-token') {
3191
+ if (request.method !== 'DELETE') {
3192
+ methodNotAllowed(response, 'Use DELETE for /cli/agent-tokens/:id.');
3193
+ return;
3194
+ }
3195
+
3196
+ if (!requestToken) {
3197
+ unauthorized(response, request);
3198
+ return;
3199
+ }
3200
+
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)
3208
+ : null;
3209
+ if (!managementAccount) {
3210
+ unauthorized(response, request);
3211
+ return;
3212
+ }
3213
+
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
+ }
2886
3226
 
2887
3227
  if (route.command === 'mobile-sync-bootstrap') {
2888
3228
  if (request.method !== 'GET') {
@@ -2929,19 +3269,21 @@ export function createSyncServiceRequestHandler({
2929
3269
  }
2930
3270
 
2931
3271
  if (route.command === 'score-snapshots') {
2932
- const account = connectedWriteAuthenticator
2933
- ? await connectedWriteAuthenticator(requestToken)
2934
- : null;
2935
- if (!account) {
2936
- unauthorized(response, request);
2937
- return;
2938
- }
2939
-
2940
3272
  if (request.method === 'POST') {
2941
3273
  if (!insertScoreSnapshotsForAccount) {
2942
3274
  methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
2943
3275
  return;
2944
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
+ }
2945
3287
  try {
2946
3288
  const body = await readJsonBody(request);
2947
3289
  if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
@@ -2974,6 +3316,13 @@ export function createSyncServiceRequestHandler({
2974
3316
  }
2975
3317
 
2976
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
+ }
2977
3326
  if (!listScoreSnapshotsForAccount) {
2978
3327
  methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
2979
3328
  return;
@@ -3044,6 +3393,19 @@ export function createSyncServiceRequestHandler({
3044
3393
  return;
3045
3394
  }
3046
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
+
3047
3409
  try {
3048
3410
  await deleteAccountForUser(deleteAccount.id);
3049
3411
  logRequest(request, 200);
@@ -3068,6 +3430,9 @@ export function createSyncServiceRequestHandler({
3068
3430
  ? { id: 'remote-user', email: null }
3069
3431
  : null;
3070
3432
  if (!proposalAccount) {
3433
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3434
+ return;
3435
+ }
3071
3436
  unauthorized(response, request);
3072
3437
  return;
3073
3438
  }
@@ -3136,6 +3501,9 @@ export function createSyncServiceRequestHandler({
3136
3501
  ? { id: 'remote-user', email: null }
3137
3502
  : null;
3138
3503
  if (!proposalAccount) {
3504
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3505
+ return;
3506
+ }
3139
3507
  unauthorized(response, request);
3140
3508
  return;
3141
3509
  }
@@ -3174,6 +3542,9 @@ export function createSyncServiceRequestHandler({
3174
3542
  ? await connectedWriteAuthenticator(requestToken)
3175
3543
  : null;
3176
3544
  if (!account) {
3545
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3546
+ return;
3547
+ }
3177
3548
  unauthorized(response, request);
3178
3549
  return;
3179
3550
  }
@@ -3258,6 +3629,9 @@ export function createSyncServiceRequestHandler({
3258
3629
  ? await connectedWriteAuthenticator(requestToken)
3259
3630
  : null;
3260
3631
  if (!account) {
3632
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3633
+ return;
3634
+ }
3261
3635
  unauthorized(response, request);
3262
3636
  return;
3263
3637
  }
@@ -3299,7 +3673,8 @@ export function createSyncServiceRequestHandler({
3299
3673
  tokenBootstrap: Boolean(issueSession || token),
3300
3674
  deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
3301
3675
  browserApproval: Boolean(approveDeviceChallenge),
3302
- devEmail: Boolean(issueDevLogin)
3676
+ devEmail: Boolean(issueDevLogin),
3677
+ agentTokens: Boolean(issueAgentToken && listAgentTokens && revokeAgentToken)
3303
3678
  }
3304
3679
  }));
3305
3680
  return;
@@ -3626,6 +4001,9 @@ export function createSyncServiceRequestHandler({
3626
4001
  ? account
3627
4002
  : null;
3628
4003
  if (!writeAccount) {
4004
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4005
+ return;
4006
+ }
3629
4007
  unauthorized(response, request);
3630
4008
  return;
3631
4009
  }
@@ -3680,6 +4058,18 @@ export function createSyncServiceRequestHandler({
3680
4058
  json(response, 503, { error: 'Coach observations not available' });
3681
4059
  return;
3682
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
+ }
3683
4073
  let body = {};
3684
4074
  try {
3685
4075
  body = await readOptionalJsonBody(request);
@@ -3693,7 +4083,7 @@ export function createSyncServiceRequestHandler({
3693
4083
  return;
3694
4084
  }
3695
4085
  try {
3696
- const observation = await markCoachObservationSeenForAccount(account, route.options.id, { seenAt });
4086
+ const observation = await markCoachObservationSeenForAccount(writeAccount, route.options.id, { seenAt });
3697
4087
  if (!observation) {
3698
4088
  notFound(response, 'Observation not found');
3699
4089
  return;
@@ -3715,6 +4105,18 @@ export function createSyncServiceRequestHandler({
3715
4105
  json(response, 503, { error: 'Coach observations not available' });
3716
4106
  return;
3717
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
+ }
3718
4120
  try {
3719
4121
  await readOptionalJsonBody(request);
3720
4122
  } catch {
@@ -3722,7 +4124,7 @@ export function createSyncServiceRequestHandler({
3722
4124
  return;
3723
4125
  }
3724
4126
  try {
3725
- const observation = await dismissCoachObservationForAccount(account, route.options.id, {});
4127
+ const observation = await dismissCoachObservationForAccount(writeAccount, route.options.id, {});
3726
4128
  if (!observation) {
3727
4129
  notFound(response, 'Observation not found');
3728
4130
  return;
@@ -3735,6 +4137,126 @@ export function createSyncServiceRequestHandler({
3735
4137
  return;
3736
4138
  }
3737
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
+
3738
4260
  let snapshot;
3739
4261
  try {
3740
4262
  snapshot = loadSnapshotForAccount
@@ -3757,6 +4279,85 @@ export function createSyncServiceRequestHandler({
3757
4279
  };
3758
4280
  }
3759
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
+ };
3760
4361
 
3761
4362
  if (route.command === 'increment-score-current') {
3762
4363
  if (request.method !== 'GET') {
@@ -3791,6 +4392,12 @@ export function createSyncServiceRequestHandler({
3791
4392
  if (route.options.toolName === 'get_increment_score') {
3792
4393
  await hydrateIncrementScore(body?.historyDays ?? 14);
3793
4394
  }
4395
+ const observationOptions = coachToolObservationOptions(route.options.toolName, body ?? {});
4396
+ if (observationOptions) {
4397
+ snapshot.coachObservations = listCurrentCoachObservationsForAccount
4398
+ ? await listCurrentCoachObservationsForAccount(account, observationOptions) ?? []
4399
+ : [];
4400
+ }
3794
4401
  json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
3795
4402
  return;
3796
4403
  } catch (error) {
@@ -3949,52 +4556,11 @@ export function createSyncServiceRequestHandler({
3949
4556
  }
3950
4557
  // Lazy-gen path: if scheduled and overdue, attempt to generate now.
3951
4558
  if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
3952
- const openrouterKey = process.env.OPENROUTER_API_KEY;
3953
4559
  let recap = null;
3954
- if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3955
- try {
3956
- const { weeklyCheckinContext } = await import('./queries.js');
3957
- // Anchor to row.weekStartDate so lazy-gen describes the
3958
- // canonical week, matching cron behaviour (onemore-8oc5).
3959
- const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
3960
- const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
3961
- if (ctx) {
3962
- let priorCommitmentRow = null;
3963
- if (listActiveCoachCommitmentsForAccount) {
3964
- try {
3965
- const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
3966
- limit: 1,
3967
- weekStartDate: ctx.weekRangeIso?.start
3968
- });
3969
- priorCommitmentRow = activeCommitments[0] ?? null;
3970
- } catch (commitmentErr) {
3971
- console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
3972
- }
3973
- }
3974
- const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
3975
- apiKey: openrouterKey,
3976
- user: aiUser,
3977
- sessionId: `weekly-checkin:${row.id}:recap`,
3978
- priorCommitment: priorCommitmentRow?.commitment ?? null,
3979
- contextMetadata: {
3980
- coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
3981
- }
3982
- });
3983
- const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
3984
- apiKey: openrouterKey,
3985
- user: aiUser,
3986
- sessionId: `weekly-checkin:${row.id}:questions`
3987
- });
3988
- recap = {
3989
- recapText: recapResult.text,
3990
- questions: questionsResult.questions,
3991
- model: recapResult.model,
3992
- generatedAt: new Date().toISOString()
3993
- };
3994
- }
3995
- } catch (genErr) {
3996
- console.error('Lazy weekly-checkin gen failed:', genErr.message);
3997
- }
4560
+ try {
4561
+ recap = await generateWeeklyCheckinRecapPayload(row);
4562
+ } catch (genErr) {
4563
+ console.error('Lazy weekly-checkin gen failed:', genErr.message);
3998
4564
  }
3999
4565
  if (!recap) {
4000
4566
  recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
@@ -4012,12 +4578,14 @@ export function createSyncServiceRequestHandler({
4012
4578
  }
4013
4579
  }
4014
4580
  }
4581
+ const freshness = await weeklyCheckinFreshnessForRow(row);
4015
4582
  json(response, 200, {
4016
4583
  id: row.id,
4017
4584
  weekStartDate: row.weekStartDate,
4018
4585
  status: row.status,
4019
4586
  recap: row.recap,
4020
- conversationId: row.conversationId
4587
+ conversationId: row.conversationId,
4588
+ freshness
4021
4589
  });
4022
4590
  } catch (err) {
4023
4591
  console.error('Weekly check-in current error:', err.message);
@@ -4074,7 +4642,10 @@ export function createSyncServiceRequestHandler({
4074
4642
  json(response, 503, { error: 'Weekly check-in not available' });
4075
4643
  return;
4076
4644
  }
4077
- 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);
4078
4649
  if (!row) {
4079
4650
  notFound(response, 'No weekly check-in scheduled.');
4080
4651
  return;
@@ -4084,9 +4655,9 @@ export function createSyncServiceRequestHandler({
4084
4655
  return;
4085
4656
  }
4086
4657
  const conversationId = `weekly-checkin:${row.id}`;
4087
- const recapText = String(row.recap.recapText ?? '').trim();
4088
- const questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
4089
- 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');
4090
4661
  try {
4091
4662
  const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
4092
4663
  const existingConversation = getAskConversationForAccount
@@ -4102,6 +4673,61 @@ export function createSyncServiceRequestHandler({
4102
4673
  });
4103
4674
  return;
4104
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');
4105
4731
  if (row.status !== 'in_progress') {
4106
4732
  let openedRow = row;
4107
4733
  if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
@@ -4333,12 +4959,128 @@ export function createSyncServiceRequestHandler({
4333
4959
  return;
4334
4960
  }
4335
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
+
4336
5065
  if (route.command === 'ask-ai') {
4337
5066
  if (request.method !== 'POST') {
4338
5067
  methodNotAllowed(response, 'Use POST for /cli/ask.');
4339
5068
  return;
4340
5069
  }
4341
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
+
4342
5084
  let body;
4343
5085
  try {
4344
5086
  body = await readJsonBody(request);
@@ -4371,21 +5113,23 @@ export function createSyncServiceRequestHandler({
4371
5113
  ? sanitizeHistory(persistedConversation.messages)
4372
5114
  : null;
4373
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);
4374
5121
  const openrouterKey = process.env.OPENROUTER_API_KEY;
4375
5122
  if (!openrouterKey) {
4376
5123
  json(response, 503, { error: 'AI not configured', code: 'not_configured' });
4377
5124
  return;
4378
5125
  }
4379
5126
 
4380
- const queries = await import('./queries.js');
4381
5127
  const exclude = parseExclude(body?.exclude);
4382
5128
  const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
4383
5129
  let coachFacts = [];
4384
5130
  if (listCoachFactsForAccount) {
4385
5131
  try {
4386
- const kinds = queries.coachFactKindsForAskQuestion
4387
- ? queries.coachFactKindsForAskQuestion(snapshot, question)
4388
- : [];
5132
+ const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
4389
5133
  coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
4390
5134
  } catch (factErr) {
4391
5135
  console.error('Coach facts read error (ask):', factErr.message);
@@ -4407,22 +5151,35 @@ export function createSyncServiceRequestHandler({
4407
5151
  };
4408
5152
  }
4409
5153
  let coachObservations = [];
4410
- if (listCurrentCoachObservationsForAccount) {
5154
+ if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
4411
5155
  try {
4412
- coachObservations = await listCurrentCoachObservationsForAccount(account, { limit: requestedCoachObservation ? 10 : 3 }) ?? [];
5156
+ coachObservations = await listCurrentCoachObservationsForAccount(account, {
5157
+ limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
5158
+ includeOutcomeHistory: true
5159
+ }) ?? [];
4413
5160
  } catch (observationErr) {
4414
5161
  console.error('Coach observations read error (ask):', observationErr.message);
4415
5162
  }
4416
5163
  }
5164
+ snapshot.coachObservations = coachObservations;
4417
5165
  const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
4418
-
4419
- const routedContext = coachObservationFollowUp && queries.askObservationFollowUpContext
4420
- ? queries.askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, { exclude, coachFacts })
4421
- : queries.askRoutedContext
4422
- ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations })
4423
- : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
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 });
4424
5181
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4425
- const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
5182
+ const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots, { question });
4426
5183
 
4427
5184
  const preludes = [incrementScorePrelude].filter(Boolean);
4428
5185
  const ctx = preludes.length > 0
@@ -4432,51 +5189,195 @@ export function createSyncServiceRequestHandler({
4432
5189
  const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
4433
5190
 
4434
5191
  try {
4435
- const { AI_PROMPT_VERSIONS, generateAskAnswer, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
4436
- 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;
4437
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
+ };
4438
5207
 
4439
- const askResult = await generateAsk(ctx, question, {
4440
- apiKey: openrouterKey,
4441
- history: canonicalHistory,
4442
- tone: askTone,
4443
- user: aiUser,
4444
- sessionId: `ask:${conversationId}`,
4445
- systemPrompt: systemPromptOverride,
4446
- routingMetadata: {
4447
- ...routedContext.metadata,
4448
- contextCharCount: ctx.length,
4449
- historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4450
- coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4451
- coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4452
- coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
4453
- coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
4454
- }
4455
- });
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
+ });
4456
5221
 
4457
- const parsedAsk = extractAskProgramDraft(askResult.text, {
4458
- canonicalizeExerciseName: queries.canonicalExerciseName
4459
- });
4460
- 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
+ }
4461
5277
  // Check for system prompt leakage before persisting. We inspect only
4462
5278
  // the user-visible prose, not the structured draft payload, so valid
4463
5279
  // <program_draft> output does not false-positive as a prompt leak.
4464
- const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
4465
- if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
5280
+ if (detectSystemPromptLeak(attempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
4466
5281
  console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
4467
5282
  onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
4468
- 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 });
4469
5284
  return;
4470
5285
  }
4471
5286
 
4472
- const promptSurface = persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask';
4473
- const promptVersion = persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.ask;
4474
- const metadata = buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, askResult);
4475
- const updatedMessages = [
4476
- ...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,
4477
5378
  { role: 'user', content: question },
4478
- { role: 'assistant', content: assistantAnswer }
4479
- ];
5379
+ { role: 'assistant', content: assistantAnswer, structured }
5380
+ ], { allowStructured: true });
4480
5381
  if (saveAskConversationForAccount) {
4481
5382
  try {
4482
5383
  await saveAskConversationForAccount(account, {
@@ -4564,7 +5465,9 @@ export function createSyncServiceRequestHandler({
4564
5465
  answer: assistantAnswer,
4565
5466
  model: askResult.model,
4566
5467
  metadata,
4567
- programDraft: parsedAsk.programDraft
5468
+ structured,
5469
+ programDraft,
5470
+ planChangeset
4568
5471
  });
4569
5472
  } catch (err) {
4570
5473
  console.error('AI ask error:', err.message);
@@ -4812,6 +5715,10 @@ export function createSyncServiceRequestHandler({
4812
5715
  return;
4813
5716
  }
4814
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
+ }
4815
5722
  if (!report) {
4816
5723
  notFound(response, 'User report not found.');
4817
5724
  return;