smart-context-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { countTokens } from '../tokenCounter.js';
2
2
  import { persistMetrics } from '../metrics.js';
3
- import { enforceRepoSafety, getRepoSafety } from '../repo-safety.js';
3
+ import { getRepoMutationSafety, getRepoSafety } from '../repo-safety.js';
4
+ import { attachSafetyMetadata, buildMutationSafety } from '../utils/mutation-safety.js';
4
5
  import { recordToolUsage } from '../usage-feedback.js';
5
6
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
6
7
  import { recordDevctxOperation } from '../missed-opportunities.js';
@@ -9,6 +10,7 @@ import {
9
10
  SQLITE_SCHEMA_VERSION,
10
11
  cleanupLegacyState,
11
12
  compactState,
13
+ getStateStorageHealth,
12
14
  importLegacyState,
13
15
  withStateDb,
14
16
  withStateDbSnapshot,
@@ -105,11 +107,6 @@ const CHECKPOINT_POLICY_BY_EVENT = {
105
107
  },
106
108
  };
107
109
  const SUMMARY_WRITE_ACTIONS = new Set(['update', 'append', 'auto_append', 'checkpoint', 'reset', 'compact']);
108
- const HARD_BLOCK_REPO_SAFETY_REASONS = [
109
- ['tracked', 'isTracked'],
110
- ['staged', 'isStaged'],
111
- ];
112
-
113
110
  const getTimestamp = (value, fallback = Date.now()) => {
114
111
  const parsed = Date.parse(value ?? '');
115
112
  return Number.isFinite(parsed) ? parsed : fallback;
@@ -974,22 +971,21 @@ const buildResumeCandidates = (sessions) =>
974
971
  isStale: session.isStale,
975
972
  }));
976
973
 
977
- const addRepoSafety = (result, repoSafety = getRepoSafety()) => ({
978
- ...result,
979
- repoSafety,
980
- });
974
+ const addRepoSafety = (result, repoSafety = getRepoSafety(), sideEffectsSuppressed = false) =>
975
+ attachSafetyMetadata({
976
+ ...result,
977
+ storageHealth: getStateStorageHealth(),
978
+ }, {
979
+ repoSafety,
980
+ sideEffectsSuppressed,
981
+ subject: 'Project-local context writes',
982
+ degradedReason: 'repo_safety_blocked',
983
+ degradedMode: 'read_only_snapshot',
984
+ degradedImpact: 'Checkpoint maintenance side effects are paused while git hygiene is blocked.',
985
+ });
981
986
 
982
987
  const getMutationSafetyPolicy = () => {
983
- const repoSafety = enforceRepoSafety();
984
- const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
985
- .filter(([, field]) => repoSafety[field])
986
- .map(([reason]) => reason);
987
-
988
- return {
989
- repoSafety,
990
- shouldBlock: reasons.length > 0,
991
- reasons,
992
- };
988
+ return getRepoMutationSafety();
993
989
  };
994
990
 
995
991
  const buildMutationBlockedMessage = (reasons, stateDbPath = '.devctx/state.sqlite') => {
@@ -1008,15 +1004,20 @@ const buildMutationBlockedMessage = (reasons, stateDbPath = '.devctx/state.sqlit
1008
1004
  return `Refused to mutate project-local context because ${stateDbPath} failed runtime safety checks.`;
1009
1005
  };
1010
1006
 
1011
- const buildMutationBlockedResponse = ({ action, sessionId, repoSafety, reasons }) =>
1012
- addRepoSafety({
1007
+ const buildMutationBlockedResponse = ({ action, sessionId, repoSafety, reasons }) => {
1008
+ const mutationSafety = buildMutationSafety(repoSafety, {
1009
+ subject: 'Project-local context writes',
1010
+ });
1011
+
1012
+ return addRepoSafety({
1013
1013
  action,
1014
1014
  sessionId: sessionId ?? null,
1015
1015
  blocked: true,
1016
1016
  mutationBlocked: true,
1017
- blockedBy: reasons,
1017
+ blockedBy: mutationSafety?.blockedBy ?? reasons,
1018
1018
  message: buildMutationBlockedMessage(reasons, repoSafety.stateDbPath ?? '.devctx/state.sqlite'),
1019
1019
  }, repoSafety);
1020
+ };
1020
1021
 
1021
1022
  const resolveAutoResumeTarget = (db, { forceRecommended = false, cleanup = true } = {}) => {
1022
1023
  const sessions = listSessions(db, { cleanup });
@@ -1158,7 +1159,7 @@ export const smartSummary = async ({
1158
1159
  activeSessionId,
1159
1160
  totalSessions: sessions.length,
1160
1161
  staleSessions: sessions.filter((session) => session.isStale).length,
1161
- });
1162
+ }, mutationSafety.repoSafety, !allowReadSideEffects);
1162
1163
  }, allowReadSideEffects ? undefined : {});
1163
1164
  }
1164
1165
 
@@ -1194,7 +1195,7 @@ export const smartSummary = async ({
1194
1195
  candidates: resolution.candidates,
1195
1196
  recommendedSessionId: resolution.recommendedSessionId,
1196
1197
  message: resolution.message,
1197
- });
1198
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1198
1199
  }
1199
1200
 
1200
1201
  targetSessionId = resolution.sessionId;
@@ -1207,7 +1208,7 @@ export const smartSummary = async ({
1207
1208
  sessionId: null,
1208
1209
  found: false,
1209
1210
  message: 'No active session found. Use action=update to create one.',
1210
- });
1211
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1211
1212
  }
1212
1213
 
1213
1214
  const session = hydrateSession(getSessionRow(db, targetSessionId));
@@ -1217,7 +1218,7 @@ export const smartSummary = async ({
1217
1218
  sessionId: targetSessionId,
1218
1219
  found: false,
1219
1220
  message: 'Session not found.',
1220
- });
1221
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1221
1222
  }
1222
1223
 
1223
1224
  if (resumeMeta?.autoResumed && allowReadSideEffects) {
@@ -1277,10 +1278,9 @@ export const smartSummary = async ({
1277
1278
  ambiguous: resumeMeta?.ambiguous ?? false,
1278
1279
  recommendedSessionId: resumeMeta?.recommendedSessionId ?? targetSessionId,
1279
1280
  ...(resumeMeta?.candidates ? { candidates: resumeMeta.candidates } : {}),
1280
- ...(suppressReadSideEffects ? { sideEffectsSuppressed: true } : {}),
1281
1281
  schemaVersion: session.schemaVersion ?? 1,
1282
1282
  updatedAt: session.updatedAt,
1283
- });
1283
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1284
1284
  }, allowReadSideEffects ? undefined : {});
1285
1285
  }
1286
1286
 
@@ -1,10 +1,27 @@
1
+ import { buildIndexIncremental, persistIndex } from '../index.js';
2
+ import { projectRoot } from '../utils/runtime-config.js';
3
+ import {
4
+ autoTrackWorkflow,
5
+ endWorkflow,
6
+ getActiveWorkflowForSession,
7
+ isWorkflowTrackingEnabled,
8
+ } from '../workflow-tracker.js';
9
+ import { persistMetrics } from '../metrics.js';
10
+ import { PRODUCT_QUALITY_ANALYTICS_KIND } from '../analytics/product-quality.js';
11
+ import { attachSafetyMetadata, buildMutationSafety } from '../utils/mutation-safety.js';
12
+ import { smartContext } from './smart-context.js';
1
13
  import { smartMetrics } from './smart-metrics.js';
2
14
  import { smartSummary } from './smart-summary.js';
3
15
 
4
16
  const DEFAULT_START_MAX_TOKENS = 400;
5
17
  const DEFAULT_END_MAX_TOKENS = 500;
6
18
  const DEFAULT_END_EVENT = 'milestone';
19
+ const DEFAULT_REFRESH_CONTEXT_MAX_TOKENS = 1400;
7
20
  const MAX_PROMPT_PREVIEW = 160;
21
+ const REFRESHED_CONTEXT_FILE_LIMIT = 3;
22
+ const SAFE_CONTINUITY_STATES = new Set(['aligned', 'resume']);
23
+ const WORKFLOW_END_EVENTS = new Set(['milestone', 'task_complete', 'session_end', 'blocker']);
24
+ const INDEX_REFRESH_STATES = new Set(['stale', 'unavailable']);
8
25
  const STOP_WORDS = new Set([
9
26
  'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'where',
10
27
  'what', 'have', 'will', 'your', 'about', 'there', 'their', 'then', 'than',
@@ -71,14 +88,24 @@ const summarizeMetrics = (metrics) => {
71
88
  count: metrics.summary.count,
72
89
  savedTokens: metrics.summary.savedTokens,
73
90
  savingsPct: metrics.summary.savingsPct,
91
+ netSavedTokens: metrics.summary.netSavedTokens,
92
+ netSavingsPct: metrics.summary.netSavingsPct,
93
+ overheadTokens: metrics.summary.overheadTokens,
74
94
  topTools: metrics.summary.tools.slice(0, 3).map((tool) => ({
75
95
  tool: tool.tool,
76
96
  savedTokens: tool.savedTokens,
97
+ netSavedTokens: tool.netSavedTokens,
77
98
  count: tool.count,
78
99
  })),
79
100
  };
80
101
  };
81
102
 
103
+ const buildRecommendedStep = (tool, instruction, priority = 'recommended') => ({
104
+ tool,
105
+ instruction,
106
+ priority,
107
+ });
108
+
82
109
  const classifyContinuity = ({ prompt, summaryResult }) => {
83
110
  if (!summaryResult?.found) {
84
111
  if (summaryResult?.ambiguous) {
@@ -163,6 +190,236 @@ const buildAutoCreateUpdate = (prompt) => ({
163
190
  nextStep: 'Inspect relevant code, confirm the task boundaries, and checkpoint the first milestone.',
164
191
  });
165
192
 
193
+ const summarizeRefreshedContext = (result, { indexRefreshed = false } = {}) => {
194
+ if (!result?.success) {
195
+ return null;
196
+ }
197
+
198
+ return {
199
+ indexFreshness: result.indexFreshness ?? 'unavailable',
200
+ indexRefreshed,
201
+ graphCoverage: result.graphCoverage ?? result.confidence?.graphCoverage ?? null,
202
+ hints: Array.isArray(result.hints) ? result.hints.slice(0, 2) : [],
203
+ topFiles: Array.isArray(result.context)
204
+ ? result.context.slice(0, REFRESHED_CONTEXT_FILE_LIMIT).map((item) => ({
205
+ file: item.file,
206
+ role: item.role,
207
+ readMode: item.readMode ?? null,
208
+ reasonIncluded: item.reasonIncluded ?? null,
209
+ symbols: Array.isArray(item.symbols) ? item.symbols.slice(0, 3) : [],
210
+ }))
211
+ : [],
212
+ };
213
+ };
214
+
215
+ const refreshPromptContext = async (prompt) => {
216
+ if (!hasMeaningfulPrompt(prompt)) {
217
+ return null;
218
+ }
219
+
220
+ const buildContext = async () => smartContext({
221
+ task: prompt,
222
+ detail: 'minimal',
223
+ include: ['hints'],
224
+ maxTokens: DEFAULT_REFRESH_CONTEXT_MAX_TOKENS,
225
+ });
226
+
227
+ let result = await buildContext();
228
+ let indexRefreshed = false;
229
+
230
+ if (INDEX_REFRESH_STATES.has(result?.indexFreshness ?? '')) {
231
+ try {
232
+ const { index } = buildIndexIncremental(projectRoot);
233
+ await persistIndex(index, projectRoot);
234
+ indexRefreshed = true;
235
+ result = await buildContext();
236
+ } catch {
237
+ // best-effort refresh only
238
+ }
239
+ }
240
+
241
+ return summarizeRefreshedContext(result, { indexRefreshed });
242
+ };
243
+
244
+ const shouldIsolateSession = ({ sessionId, ensureSession, prompt, continuity }) =>
245
+ !sessionId
246
+ && ensureSession
247
+ && hasMeaningfulPrompt(prompt)
248
+ && continuity
249
+ && !SAFE_CONTINUITY_STATES.has(continuity.state ?? '');
250
+
251
+ const shouldRefreshContext = ({ prompt, ensureSession, summaryResult, continuity, isolatedSession, autoCreated }) =>
252
+ hasMeaningfulPrompt(prompt)
253
+ && (
254
+ isolatedSession
255
+ || autoCreated
256
+ || (ensureSession && (summaryResult?.ambiguous || !summaryResult?.found))
257
+ || ['possible_shift', 'context_mismatch'].includes(continuity?.state)
258
+ );
259
+
260
+ const buildStartRecommendedPath = ({
261
+ prompt,
262
+ ensureSession,
263
+ summaryResult,
264
+ continuity,
265
+ refreshedContext,
266
+ mutationSafety,
267
+ autoCreated,
268
+ isolatedSession,
269
+ }) => {
270
+ const nextTools = [];
271
+ const steps = [];
272
+
273
+ if (mutationSafety?.blocked) {
274
+ nextTools.push('repo_safety', 'smart_search', 'smart_read');
275
+ steps.push(buildRecommendedStep(
276
+ 'repo_safety',
277
+ 'Pause persisted-write workflows, surface blockedBy, and follow recommendedActions before retrying checkpoints or workflow tracking.',
278
+ 'required',
279
+ ));
280
+ steps.push(buildRecommendedStep(
281
+ 'smart_search',
282
+ 'Continue with read-only exploration until repo safety is fixed.',
283
+ 'recommended',
284
+ ));
285
+ } else if (refreshedContext?.topFiles?.length) {
286
+ nextTools.push('smart_read', 'smart_turn');
287
+ steps.push(buildRecommendedStep(
288
+ 'smart_read',
289
+ 'Start from refreshedContext.topFiles with smart_read(outline|signatures|symbol) before any full reads.',
290
+ ));
291
+ } else if (hasMeaningfulPrompt(prompt)) {
292
+ nextTools.push('smart_context', 'smart_read', 'smart_turn');
293
+ steps.push(buildRecommendedStep(
294
+ 'smart_context',
295
+ 'Build focused multi-file context with smart_context(...) or smart_search(intent=...) before opening more files.',
296
+ ));
297
+ steps.push(buildRecommendedStep(
298
+ 'smart_read',
299
+ 'Use smart_read(outline|signatures|symbol) as the default follow-up read path.',
300
+ ));
301
+ } else {
302
+ nextTools.push('smart_search', 'smart_read');
303
+ steps.push(buildRecommendedStep(
304
+ 'smart_search',
305
+ 'Stay lightweight: only use devctx search/read if the task grows beyond a trivial lookup.',
306
+ ));
307
+ }
308
+
309
+ if (summaryResult?.ambiguous && summaryResult?.recommendedSessionId) {
310
+ nextTools.unshift('smart_turn');
311
+ steps.unshift(buildRecommendedStep(
312
+ 'smart_turn',
313
+ `Reuse or pass sessionId=${summaryResult.recommendedSessionId} if you want to resume the recommended persisted session explicitly.`,
314
+ 'required',
315
+ ));
316
+ }
317
+
318
+ if (!mutationSafety?.blocked) {
319
+ nextTools.push('smart_turn');
320
+ steps.push(buildRecommendedStep(
321
+ 'smart_turn',
322
+ 'Checkpoint with smart_turn(end, event=milestone) after the first meaningful progress point.',
323
+ ));
324
+ }
325
+
326
+ return {
327
+ phase: 'start',
328
+ mode: mutationSafety?.blocked
329
+ ? 'blocked_guided'
330
+ : refreshedContext
331
+ ? 'guided_refresh'
332
+ : hasMeaningfulPrompt(prompt)
333
+ ? 'guided_context'
334
+ : 'lightweight',
335
+ contextSource: refreshedContext
336
+ ? 'refreshed_context'
337
+ : continuity?.shouldReuseContext
338
+ ? 'persisted_summary'
339
+ : 'direct_prompt',
340
+ continuityState: continuity?.state ?? null,
341
+ ensureSessionRecommended: Boolean(hasMeaningfulPrompt(prompt) && (ensureSession || !summaryResult?.found)),
342
+ autoCreated,
343
+ isolatedSession,
344
+ nextTools: [...new Set(nextTools)],
345
+ steps,
346
+ };
347
+ };
348
+
349
+ const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow }) => {
350
+ const nextTools = [];
351
+ const steps = [];
352
+
353
+ if (mutationSafety?.blocked) {
354
+ nextTools.push('repo_safety', 'smart_search', 'smart_read');
355
+ steps.push(buildRecommendedStep(
356
+ 'repo_safety',
357
+ 'Fix repo safety before expecting checkpoints, workflow tracking, or hook state writes to persist.',
358
+ 'required',
359
+ ));
360
+ } else if (checkpoint?.skipped) {
361
+ nextTools.push('smart_turn');
362
+ steps.push(buildRecommendedStep(
363
+ 'smart_turn',
364
+ 'No durable checkpoint was written; keep working and call smart_turn(end, event=milestone) once you have a concrete milestone or next step.',
365
+ 'required',
366
+ ));
367
+ } else {
368
+ nextTools.push('smart_turn');
369
+ steps.push(buildRecommendedStep(
370
+ 'smart_turn',
371
+ 'On the next substantial prompt, restart with smart_turn(start, prompt, ensureSession=true) to reuse this checkpoint cleanly.',
372
+ ));
373
+ }
374
+
375
+ if (workflow?.ended) {
376
+ nextTools.push('smart_turn');
377
+ steps.push(buildRecommendedStep(
378
+ 'smart_turn',
379
+ 'This workflow is closed; start a fresh turn for the next substantial task boundary.',
380
+ ));
381
+ }
382
+
383
+ return {
384
+ phase: 'end',
385
+ mode: mutationSafety?.blocked
386
+ ? 'blocked_guided'
387
+ : checkpoint?.skipped
388
+ ? 'continue_until_milestone'
389
+ : 'checkpointed',
390
+ checkpointEvent: event,
391
+ nextTools: [...new Set(nextTools)],
392
+ steps,
393
+ };
394
+ };
395
+
396
+ const persistSmartTurnQualityMetrics = async ({
397
+ phase,
398
+ sessionId,
399
+ target,
400
+ action,
401
+ latencyMs,
402
+ metadata,
403
+ }) => {
404
+ await persistMetrics({
405
+ tool: 'smart_turn',
406
+ action,
407
+ sessionId,
408
+ target,
409
+ rawTokens: 0,
410
+ compressedTokens: 0,
411
+ savedTokens: 0,
412
+ savingsPct: 0,
413
+ latencyMs,
414
+ metadata: {
415
+ analyticsKind: PRODUCT_QUALITY_ANALYTICS_KIND,
416
+ phase,
417
+ ...metadata,
418
+ },
419
+ timestamp: new Date().toISOString(),
420
+ });
421
+ };
422
+
166
423
  const startTurn = async ({
167
424
  sessionId,
168
425
  prompt,
@@ -172,6 +429,7 @@ const startTurn = async ({
172
429
  metricsWindow = '7d',
173
430
  latestMetrics = 5,
174
431
  } = {}) => {
432
+ const startTime = Date.now();
175
433
  let summaryResult = await smartSummary({
176
434
  action: 'get',
177
435
  sessionId,
@@ -179,6 +437,8 @@ const startTurn = async ({
179
437
  });
180
438
 
181
439
  let autoCreated = false;
440
+ let isolatedSession = false;
441
+ let previousSessionId = null;
182
442
  if (!summaryResult.found && !summaryResult.ambiguous && ensureSession && hasMeaningfulPrompt(prompt)) {
183
443
  const created = await smartSummary({
184
444
  action: 'update',
@@ -195,8 +455,69 @@ const startTurn = async ({
195
455
  }
196
456
  }
197
457
 
198
- const continuity = classifyContinuity({ prompt, summaryResult });
458
+ let continuity = classifyContinuity({ prompt, summaryResult });
459
+
460
+ if (summaryResult.found && shouldIsolateSession({ sessionId, ensureSession, prompt, continuity })) {
461
+ const created = await smartSummary({
462
+ action: 'update',
463
+ update: buildAutoCreateUpdate(prompt),
464
+ maxTokens,
465
+ });
466
+
467
+ if (!created.blocked) {
468
+ isolatedSession = true;
469
+ previousSessionId = summaryResult.sessionId ?? null;
470
+ summaryResult = await smartSummary({
471
+ action: 'get',
472
+ sessionId: created.sessionId,
473
+ maxTokens,
474
+ });
475
+ continuity = classifyContinuity({ prompt, summaryResult });
476
+ }
477
+ }
478
+
199
479
  const effectiveSessionId = summaryResult.sessionId ?? sessionId ?? summaryResult.recommendedSessionId ?? null;
480
+ const mutationSafety = buildMutationSafety(summaryResult.repoSafety);
481
+ const workflowBlocked = Boolean(isWorkflowTrackingEnabled() && mutationSafety?.blocked);
482
+ const refreshedContext = shouldRefreshContext({
483
+ prompt,
484
+ ensureSession,
485
+ summaryResult,
486
+ continuity,
487
+ isolatedSession,
488
+ autoCreated,
489
+ })
490
+ ? await refreshPromptContext(prompt)
491
+ : null;
492
+
493
+ let workflow = null;
494
+ if (workflowBlocked) {
495
+ workflow = { enabled: true, blocked: true, workflowId: null, workflowType: null, autoTracked: false };
496
+ } else if (effectiveSessionId && isWorkflowTrackingEnabled()) {
497
+ const workflowId = await autoTrackWorkflow(
498
+ effectiveSessionId,
499
+ summaryResult.summary?.goal ?? prompt ?? '',
500
+ );
501
+ if (workflowId) {
502
+ const activeWorkflow = await getActiveWorkflowForSession(effectiveSessionId);
503
+ workflow = activeWorkflow
504
+ ? {
505
+ enabled: true,
506
+ workflowId: activeWorkflow.workflow_id,
507
+ workflowType: activeWorkflow.workflow_type,
508
+ autoTracked: true,
509
+ }
510
+ : {
511
+ enabled: true,
512
+ workflowId,
513
+ workflowType: null,
514
+ autoTracked: true,
515
+ };
516
+ } else {
517
+ workflow = { enabled: true, workflowId: null, workflowType: null, autoTracked: false };
518
+ }
519
+ }
520
+
200
521
  const metrics = includeMetrics
201
522
  ? await smartMetrics({
202
523
  window: metricsWindow,
@@ -205,26 +526,76 @@ const startTurn = async ({
205
526
  })
206
527
  : null;
207
528
 
529
+ const recommendedPath = buildStartRecommendedPath({
530
+ prompt,
531
+ ensureSession,
532
+ summaryResult,
533
+ continuity,
534
+ refreshedContext,
535
+ mutationSafety,
536
+ autoCreated,
537
+ isolatedSession,
538
+ });
208
539
 
209
- return {
540
+ await persistSmartTurnQualityMetrics({
541
+ phase: 'start',
542
+ sessionId: effectiveSessionId ?? null,
543
+ target: truncate(prompt, 120) || 'smart_turn:start',
544
+ action: 'start',
545
+ latencyMs: Date.now() - startTime,
546
+ metadata: {
547
+ continuityState: continuity?.state ?? null,
548
+ shouldReuseContext: Boolean(continuity?.shouldReuseContext),
549
+ sessionFound: Boolean(summaryResult.found),
550
+ ambiguousResume: Boolean(summaryResult.ambiguous),
551
+ autoCreated,
552
+ isolatedSession,
553
+ previousSessionId,
554
+ mutationBlocked: Boolean(mutationSafety?.blocked),
555
+ blockedBy: mutationSafety?.blockedBy ?? [],
556
+ recommendedActionsCount: mutationSafety?.recommendedActions?.length ?? 0,
557
+ refreshedContext: Boolean(refreshedContext),
558
+ refreshedTopFiles: refreshedContext?.topFiles?.length ?? 0,
559
+ indexRefreshed: Boolean(refreshedContext?.indexRefreshed),
560
+ recommendedPathMode: recommendedPath.mode,
561
+ nextToolsCount: recommendedPath.nextTools.length,
562
+ workflowEnabled: Boolean(workflow?.enabled),
563
+ workflowAutoTracked: Boolean(workflow?.autoTracked),
564
+ },
565
+ });
566
+
567
+ return attachSafetyMetadata({
210
568
  phase: 'start',
211
569
  promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW),
212
570
  sessionId: effectiveSessionId,
213
571
  found: summaryResult.found ?? false,
214
572
  autoCreated,
573
+ isolatedSession,
574
+ ...(previousSessionId ? { previousSessionId } : {}),
215
575
  continuity,
216
576
  summary: summaryResult.summary ?? null,
217
- repoSafety: summaryResult.repoSafety ?? metrics?.repoSafety ?? null,
218
- sideEffectsSuppressed: Boolean(summaryResult.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
577
+ ...(refreshedContext ? { refreshedContext } : {}),
578
+ ...(workflow ? { workflow } : {}),
219
579
  ...(summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
220
580
  ...(summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
221
581
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
222
- message: summaryResult.found
223
- ? continuity.reason
224
- : autoCreated
225
- ? 'Created a new persisted session for this task prompt.'
226
- : continuity.reason,
227
- };
582
+ storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth ?? null,
583
+ recommendedPath,
584
+ message: mutationSafety?.blocked
585
+ ? mutationSafety.message
586
+ : summaryResult.found
587
+ ? continuity.reason
588
+ : autoCreated
589
+ ? 'Created a new persisted session for this task prompt.'
590
+ : continuity.reason,
591
+ }, {
592
+ repoSafety: summaryResult.repoSafety ?? metrics?.repoSafety ?? null,
593
+ sideEffectsSuppressed: Boolean(summaryResult.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
594
+ subject: 'Project-local context writes',
595
+ degradedReason: 'repo_safety_blocked',
596
+ degradedMode: 'read_only_snapshot',
597
+ degradedImpact: 'Checkpoint and workflow side effects are paused while git hygiene is blocked.',
598
+ });
228
599
  };
229
600
 
230
601
  const endTurn = async ({
@@ -237,6 +608,7 @@ const endTurn = async ({
237
608
  metricsWindow = '7d',
238
609
  latestMetrics = 5,
239
610
  } = {}) => {
611
+ const startTime = Date.now();
240
612
  const checkpoint = await smartSummary({
241
613
  action: 'checkpoint',
242
614
  sessionId,
@@ -247,6 +619,39 @@ const endTurn = async ({
247
619
  });
248
620
 
249
621
  const effectiveSessionId = checkpoint.sessionId ?? sessionId ?? 'active';
622
+ const mutationSafety = buildMutationSafety(checkpoint.repoSafety);
623
+ const workflowBlocked = Boolean(isWorkflowTrackingEnabled() && mutationSafety?.blocked);
624
+ let workflow = null;
625
+ if (workflowBlocked) {
626
+ workflow = { enabled: true, blocked: true, workflowId: null, workflowType: null, ended: false };
627
+ } else if (
628
+ checkpoint.sessionId
629
+ && !checkpoint.skipped
630
+ && WORKFLOW_END_EVENTS.has(event)
631
+ && isWorkflowTrackingEnabled()
632
+ ) {
633
+ const activeWorkflow = await getActiveWorkflowForSession(checkpoint.sessionId);
634
+ if (activeWorkflow?.workflow_id) {
635
+ const endedWorkflow = await endWorkflow(activeWorkflow.workflow_id);
636
+ workflow = endedWorkflow
637
+ ? {
638
+ enabled: true,
639
+ workflowId: endedWorkflow.workflowId,
640
+ workflowType: endedWorkflow.workflowType,
641
+ ended: true,
642
+ summary: endedWorkflow,
643
+ }
644
+ : {
645
+ enabled: true,
646
+ workflowId: activeWorkflow.workflow_id,
647
+ workflowType: activeWorkflow.workflow_type,
648
+ ended: false,
649
+ };
650
+ } else {
651
+ workflow = { enabled: true, workflowId: null, workflowType: null, ended: false };
652
+ }
653
+ }
654
+
250
655
  const metrics = includeMetrics
251
656
  ? await smartMetrics({
252
657
  window: metricsWindow,
@@ -255,15 +660,51 @@ const endTurn = async ({
255
660
  })
256
661
  : null;
257
662
 
258
- return {
663
+ const recommendedPath = buildEndRecommendedPath({
664
+ event,
665
+ checkpoint,
666
+ mutationSafety,
667
+ workflow,
668
+ });
669
+
670
+ await persistSmartTurnQualityMetrics({
671
+ phase: 'end',
672
+ sessionId: checkpoint.sessionId ?? sessionId ?? null,
673
+ target: event,
674
+ action: 'end',
675
+ latencyMs: Date.now() - startTime,
676
+ metadata: {
677
+ event,
678
+ checkpointSkipped: Boolean(checkpoint.skipped),
679
+ checkpointPersisted: !checkpoint.skipped && !checkpoint.blocked,
680
+ mutationBlocked: Boolean(mutationSafety?.blocked),
681
+ blockedBy: mutationSafety?.blockedBy ?? [],
682
+ recommendedActionsCount: mutationSafety?.recommendedActions?.length ?? 0,
683
+ recommendedPathMode: recommendedPath.mode,
684
+ workflowEnabled: Boolean(workflow?.enabled),
685
+ workflowEnded: Boolean(workflow?.ended),
686
+ checkpointScore: checkpoint.checkpoint?.score ?? null,
687
+ checkpointThreshold: checkpoint.checkpoint?.threshold ?? null,
688
+ },
689
+ });
690
+
691
+ return attachSafetyMetadata({
259
692
  phase: 'end',
260
693
  sessionId: checkpoint.sessionId ?? sessionId ?? null,
261
694
  checkpoint,
695
+ ...(workflow ? { workflow } : {}),
696
+ ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
697
+ storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth ?? null,
698
+ recommendedPath,
699
+ message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message,
700
+ }, {
262
701
  repoSafety: checkpoint.repoSafety ?? metrics?.repoSafety ?? null,
263
702
  sideEffectsSuppressed: Boolean(checkpoint.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
264
- ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
265
- message: checkpoint.message,
266
- };
703
+ subject: 'Project-local context writes',
704
+ degradedReason: 'repo_safety_blocked',
705
+ degradedMode: 'read_only_snapshot',
706
+ degradedImpact: 'Checkpoint and workflow side effects are paused while git hygiene is blocked.',
707
+ });
267
708
  };
268
709
 
269
710
  export const smartTurn = async ({