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.
@@ -0,0 +1,418 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getRepoMutationSafety } from '../repo-safety.js';
4
+ import {
5
+ ACTIVE_SESSION_SCOPE,
6
+ diagnoseStateStorage,
7
+ getLegacyActiveSessionPath,
8
+ getLegacyMetricsPath,
9
+ getLegacySessionsDir,
10
+ getMeta,
11
+ getStateDbPath,
12
+ withStateDbSnapshot,
13
+ } from '../storage/sqlite.js';
14
+ import { attachSafetyMetadata } from '../utils/mutation-safety.js';
15
+ import { recordToolUsage } from '../usage-feedback.js';
16
+ import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
17
+ import { recordDevctxOperation } from '../missed-opportunities.js';
18
+ import { countTokens } from '../tokenCounter.js';
19
+
20
+ const DEFAULT_RETENTION_DAYS = 30;
21
+ const DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION = 20;
22
+ const DEFAULT_KEEP_LATEST_METRICS = 1000;
23
+ const SESSION_EVENTS_WARNING_FLOOR = DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION * 10;
24
+ const METRICS_WARNING_FLOOR = Math.ceil(DEFAULT_KEEP_LATEST_METRICS * 1.5);
25
+ const STATUS_RANK = {
26
+ info: 0,
27
+ ok: 1,
28
+ warning: 2,
29
+ error: 3,
30
+ };
31
+
32
+ const uniqueStrings = (items) => [...new Set(items.filter((item) => typeof item === 'string' && item.trim().length > 0))];
33
+
34
+ const compactPath = (filePath) => {
35
+ if (!filePath) {
36
+ return filePath;
37
+ }
38
+ const normalized = filePath.replace(/\\/g, '/');
39
+ const parts = normalized.split('/').filter(Boolean);
40
+ return parts.length <= 3 ? normalized : `.../${parts.slice(-3).join('/')}`;
41
+ };
42
+
43
+ const daysSince = (isoString) => {
44
+ const timestamp = Date.parse(isoString ?? '');
45
+ if (!Number.isFinite(timestamp)) {
46
+ return null;
47
+ }
48
+ return Math.max(0, (Date.now() - timestamp) / (24 * 60 * 60 * 1000));
49
+ };
50
+
51
+ const buildCheck = ({ id, status, message, recommendedActions = [], details = {} }) => ({
52
+ id,
53
+ status,
54
+ message,
55
+ recommendedActions: uniqueStrings(recommendedActions),
56
+ details,
57
+ });
58
+
59
+ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
60
+ if (!storageHealth?.exists) {
61
+ return { available: false, reason: 'missing' };
62
+ }
63
+
64
+ if (['locked', 'corrupted', 'unavailable', 'unknown'].includes(storageHealth.issue)) {
65
+ return { available: false, reason: storageHealth.issue };
66
+ }
67
+
68
+ try {
69
+ return await withStateDbSnapshot((db) => {
70
+ const count = (tableName) => db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get().count;
71
+
72
+ return {
73
+ available: true,
74
+ counts: {
75
+ sessions: count('sessions'),
76
+ sessionEvents: count('session_events'),
77
+ metricsEvents: count('metrics_events'),
78
+ hookTurnState: count('hook_turn_state'),
79
+ workflowMetrics: count('workflow_metrics'),
80
+ },
81
+ activeSessionId: db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null,
82
+ schemaVersion: Number(getMeta(db, 'schema_version') ?? 0),
83
+ lastCompactedAt: getMeta(db, 'state_compacted_at'),
84
+ retentionDays: Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS),
85
+ legacyImport: {
86
+ sessions: Number(getMeta(db, 'legacy_sessions_import_count') ?? 0),
87
+ metrics: Number(getMeta(db, 'legacy_metrics_import_count') ?? 0),
88
+ },
89
+ };
90
+ }, { filePath });
91
+ } catch (error) {
92
+ return {
93
+ available: false,
94
+ reason: error.storageHealth?.issue ?? 'unknown',
95
+ error: error.message,
96
+ };
97
+ }
98
+ };
99
+
100
+ const inspectLegacyFiles = () => {
101
+ const sessionsDir = getLegacySessionsDir();
102
+ const metricsFile = getLegacyMetricsPath();
103
+ const activeSessionFile = getLegacyActiveSessionPath();
104
+ const sessionFiles = fs.existsSync(sessionsDir)
105
+ ? fs.readdirSync(sessionsDir)
106
+ .filter((fileName) => fileName.endsWith('.json') && fileName !== 'active.json')
107
+ .map((fileName) => path.join(sessionsDir, fileName))
108
+ : [];
109
+
110
+ return {
111
+ sessionsDir,
112
+ sessionFiles,
113
+ activeSessionFile,
114
+ activeSessionExists: fs.existsSync(activeSessionFile),
115
+ metricsFile,
116
+ metricsExists: fs.existsSync(metricsFile),
117
+ };
118
+ };
119
+
120
+ const buildRepoSafetyCheck = (mutationSafety) => {
121
+ const repoSafety = mutationSafety.repoSafety;
122
+
123
+ if (!repoSafety?.available) {
124
+ return buildCheck({
125
+ id: 'repoSafety',
126
+ status: 'info',
127
+ message: 'Repository safety checks are unavailable because this project is not inside a git repository.',
128
+ recommendedActions: [],
129
+ details: {
130
+ available: false,
131
+ },
132
+ });
133
+ }
134
+
135
+ if (mutationSafety.shouldBlock) {
136
+ return buildCheck({
137
+ id: 'repoSafety',
138
+ status: 'error',
139
+ message: 'Git hygiene is actively blocking persisted devctx writes for project-local state.',
140
+ recommendedActions: repoSafety.recommendedActions,
141
+ details: {
142
+ blockedBy: mutationSafety.reasons,
143
+ stateDbPath: repoSafety.stateDbPath,
144
+ warnings: repoSafety.warnings,
145
+ },
146
+ });
147
+ }
148
+
149
+ if (repoSafety.riskLevel === 'warning') {
150
+ return buildCheck({
151
+ id: 'repoSafety',
152
+ status: 'warning',
153
+ message: 'Repository safety is degraded: local devctx state is not fully protected against accidental commits.',
154
+ recommendedActions: repoSafety.recommendedActions,
155
+ details: {
156
+ stateDbPath: repoSafety.stateDbPath,
157
+ warnings: repoSafety.warnings,
158
+ },
159
+ });
160
+ }
161
+
162
+ return buildCheck({
163
+ id: 'repoSafety',
164
+ status: 'ok',
165
+ message: 'Repository safety checks are healthy for project-local devctx state.',
166
+ recommendedActions: [],
167
+ details: {
168
+ stateDbPath: repoSafety.stateDbPath,
169
+ projectGitignorePath: repoSafety.projectGitignorePath,
170
+ },
171
+ });
172
+ };
173
+
174
+ const buildStorageCheck = (storageHealth) => {
175
+ const status = storageHealth.status === 'error'
176
+ ? 'error'
177
+ : storageHealth.status === 'warning'
178
+ ? 'warning'
179
+ : 'ok';
180
+
181
+ return buildCheck({
182
+ id: 'storageHealth',
183
+ status,
184
+ message: storageHealth.message,
185
+ recommendedActions: storageHealth.recommendedActions,
186
+ details: {
187
+ issue: storageHealth.issue,
188
+ filePath: compactPath(storageHealth.filePath),
189
+ relativePath: storageHealth.relativePath,
190
+ sizeBytes: storageHealth.sizeBytes,
191
+ softMaxBytes: storageHealth.softMaxBytes,
192
+ integrity: storageHealth.integrity ?? null,
193
+ },
194
+ });
195
+ };
196
+
197
+ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
198
+ if (!storageHealth.exists) {
199
+ return buildCheck({
200
+ id: 'compaction',
201
+ status: 'info',
202
+ message: 'Compaction checks are not applicable until project-local SQLite state exists.',
203
+ recommendedActions: [],
204
+ details: {
205
+ available: false,
206
+ reason: 'missing',
207
+ },
208
+ });
209
+ }
210
+
211
+ if (!maintenance?.available) {
212
+ return buildCheck({
213
+ id: 'compaction',
214
+ status: storageHealth.status === 'error' ? 'warning' : 'info',
215
+ message: 'Compaction hygiene could not be inspected until SQLite storage health is repaired.',
216
+ recommendedActions: storageHealth.recommendedActions,
217
+ details: {
218
+ available: false,
219
+ reason: maintenance?.reason ?? storageHealth.issue,
220
+ },
221
+ });
222
+ }
223
+
224
+ const {
225
+ counts,
226
+ lastCompactedAt,
227
+ retentionDays,
228
+ schemaVersion,
229
+ activeSessionId,
230
+ } = maintenance;
231
+ const daysSinceCompaction = daysSince(lastCompactedAt);
232
+ const sessionEventThreshold = Math.max(SESSION_EVENTS_WARNING_FLOOR, counts.sessions * DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION * 5);
233
+ const needsCompaction = [];
234
+
235
+ if (storageHealth.issue === 'oversized') {
236
+ needsCompaction.push('database_size');
237
+ }
238
+
239
+ if (!lastCompactedAt && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
240
+ needsCompaction.push('never_compacted');
241
+ }
242
+
243
+ if (
244
+ daysSinceCompaction !== null
245
+ && daysSinceCompaction > retentionDays
246
+ && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > DEFAULT_KEEP_LATEST_METRICS)
247
+ ) {
248
+ needsCompaction.push('stale_compaction');
249
+ }
250
+
251
+ if (needsCompaction.length > 0) {
252
+ return buildCheck({
253
+ id: 'compaction',
254
+ status: 'warning',
255
+ message: 'SQLite retention/compaction hygiene should be refreshed before the local state grows further.',
256
+ recommendedActions: [
257
+ 'Run smart_summary with action="compact" to prune old events and metrics.',
258
+ 'Use vacuum=true if you expect large deletions and want to reclaim file size immediately.',
259
+ ],
260
+ details: {
261
+ available: true,
262
+ recommended: true,
263
+ reasons: needsCompaction,
264
+ schemaVersion,
265
+ activeSessionId,
266
+ counts,
267
+ lastCompactedAt,
268
+ daysSinceCompaction,
269
+ retentionDays,
270
+ },
271
+ });
272
+ }
273
+
274
+ return buildCheck({
275
+ id: 'compaction',
276
+ status: 'ok',
277
+ message: 'SQLite retention and compaction hygiene look healthy for the current local state volume.',
278
+ recommendedActions: [],
279
+ details: {
280
+ available: true,
281
+ recommended: false,
282
+ schemaVersion,
283
+ activeSessionId,
284
+ counts,
285
+ lastCompactedAt,
286
+ daysSinceCompaction,
287
+ retentionDays,
288
+ },
289
+ });
290
+ };
291
+
292
+ const buildLegacyCheck = ({ legacyFiles, maintenance }) => {
293
+ const totalArtifacts = legacyFiles.sessionFiles.length + (legacyFiles.activeSessionExists ? 1 : 0) + (legacyFiles.metricsExists ? 1 : 0);
294
+
295
+ if (totalArtifacts === 0) {
296
+ return buildCheck({
297
+ id: 'legacyState',
298
+ status: 'ok',
299
+ message: 'No legacy JSON/JSONL state artifacts were detected alongside SQLite state.',
300
+ recommendedActions: [],
301
+ details: {
302
+ present: false,
303
+ sessionFiles: 0,
304
+ metricsExists: false,
305
+ activeSessionExists: false,
306
+ },
307
+ });
308
+ }
309
+
310
+ const importedSessions = maintenance?.available ? maintenance.legacyImport.sessions : null;
311
+ const importedMetrics = maintenance?.available ? maintenance.legacyImport.metrics : null;
312
+
313
+ return buildCheck({
314
+ id: 'legacyState',
315
+ status: 'warning',
316
+ message: 'Legacy JSON/JSONL state artifacts are still present and should be reviewed for cleanup.',
317
+ recommendedActions: [
318
+ 'Run smart_summary with action="cleanup_legacy" to inspect removable legacy files.',
319
+ importedSessions || importedMetrics
320
+ ? 'If the SQLite import is already validated, rerun smart_summary cleanup_legacy with apply=true to remove eligible files.'
321
+ : 'Validate that legacy state has been imported before deleting old JSON/JSONL files.',
322
+ ],
323
+ details: {
324
+ present: true,
325
+ sessionFiles: legacyFiles.sessionFiles.length,
326
+ sampleSessionFiles: legacyFiles.sessionFiles.slice(0, 3).map(compactPath),
327
+ metricsExists: legacyFiles.metricsExists,
328
+ activeSessionExists: legacyFiles.activeSessionExists,
329
+ importedSessions,
330
+ importedMetrics,
331
+ },
332
+ });
333
+ };
334
+
335
+ const summarizeOverall = (checks) => {
336
+ const highest = checks.reduce((max, check) => Math.max(max, STATUS_RANK[check.status] ?? 0), 0);
337
+ if (highest >= STATUS_RANK.error) {
338
+ return 'error';
339
+ }
340
+ if (highest >= STATUS_RANK.warning) {
341
+ return 'warning';
342
+ }
343
+ return 'ok';
344
+ };
345
+
346
+ const buildSummaryMessage = (overall) => {
347
+ if (overall === 'error') {
348
+ return 'devctx doctor found blocking operational issues in local state or repo hygiene.';
349
+ }
350
+ if (overall === 'warning') {
351
+ return 'devctx doctor found non-blocking issues that should be cleaned up before they accumulate.';
352
+ }
353
+ return 'devctx doctor found the local state setup healthy for normal use.';
354
+ };
355
+
356
+ export const smartDoctor = async ({ verifyIntegrity = true } = {}) => {
357
+ recordDecision({
358
+ tool: 'smart_doctor',
359
+ reason: DECISION_REASONS.CONTEXT_VISIBILITY,
360
+ benefit: EXPECTED_BENEFITS.TRANSPARENCY,
361
+ });
362
+ recordDevctxOperation('smart_doctor');
363
+
364
+ const mutationSafety = getRepoMutationSafety();
365
+ const storageHealth = await diagnoseStateStorage({
366
+ filePath: getStateDbPath(),
367
+ verifyIntegrity,
368
+ });
369
+ const maintenance = await readMaintenanceSnapshot({
370
+ filePath: getStateDbPath(),
371
+ storageHealth,
372
+ });
373
+ const legacyFiles = inspectLegacyFiles();
374
+
375
+ const checks = [
376
+ buildRepoSafetyCheck(mutationSafety),
377
+ buildStorageCheck(storageHealth),
378
+ buildCompactionCheck({ storageHealth, maintenance }),
379
+ buildLegacyCheck({ legacyFiles, maintenance }),
380
+ ];
381
+
382
+ const overall = summarizeOverall(checks);
383
+ const recommendedActions = uniqueStrings(checks.flatMap((check) => check.recommendedActions));
384
+ const result = attachSafetyMetadata({
385
+ success: overall !== 'error',
386
+ overall,
387
+ message: buildSummaryMessage(overall),
388
+ checks,
389
+ recommendedActions,
390
+ storageHealth,
391
+ maintenance: maintenance?.available ? maintenance : null,
392
+ legacyState: {
393
+ sessionsDir: compactPath(legacyFiles.sessionsDir),
394
+ metricsFile: compactPath(legacyFiles.metricsFile),
395
+ activeSessionFile: compactPath(legacyFiles.activeSessionFile),
396
+ sessionFiles: legacyFiles.sessionFiles.length,
397
+ activeSessionExists: legacyFiles.activeSessionExists,
398
+ metricsExists: legacyFiles.metricsExists,
399
+ },
400
+ }, {
401
+ repoSafety: mutationSafety.repoSafety,
402
+ sideEffectsSuppressed: mutationSafety.shouldBlock,
403
+ subject: 'Project-local context writes',
404
+ degradedReason: 'repo_safety_blocked',
405
+ degradedMode: 'read_only_snapshot',
406
+ degradedImpact: 'Persistent writes remain blocked while repo safety is unhealthy.',
407
+ });
408
+
409
+ recordToolUsage({
410
+ tool: 'smart_doctor',
411
+ rawTokens: 0,
412
+ compressedTokens: countTokens(JSON.stringify(result)),
413
+ savedTokens: 0,
414
+ savingsPct: 0,
415
+ });
416
+
417
+ return result;
418
+ };
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
- import { enforceRepoSafety, getRepoSafety } from '../repo-safety.js';
2
+ import { getRepoMutationSafety, getRepoSafety } from '../repo-safety.js';
3
3
  import {
4
4
  ACTIVE_SESSION_SCOPE,
5
+ diagnoseStateStorage,
6
+ getStateStorageHealth,
5
7
  importLegacyState,
6
8
  withStateDb,
7
9
  withStateDbSnapshot,
@@ -15,6 +17,8 @@ import {
15
17
  resolveMetricsInput,
16
18
  } from '../metrics.js';
17
19
  import { analyzeAdoption } from '../analytics/adoption.js';
20
+ import { analyzeProductQuality } from '../analytics/product-quality.js';
21
+ import { attachSafetyMetadata } from '../utils/mutation-safety.js';
18
22
 
19
23
  const WINDOW_MS = {
20
24
  '24h': 24 * 60 * 60 * 1000,
@@ -62,22 +66,8 @@ const buildLatestEntries = (entries, limit) =>
62
66
 
63
67
  const getActiveSessionId = (db) =>
64
68
  db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
65
- const HARD_BLOCK_REPO_SAFETY_REASONS = [
66
- ['tracked', 'isTracked'],
67
- ['staged', 'isStaged'],
68
- ];
69
-
70
69
  const getSqliteSafetyPolicy = () => {
71
- const repoSafety = enforceRepoSafety();
72
- const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
73
- .filter(([, field]) => repoSafety[field])
74
- .map(([reason]) => reason);
75
-
76
- return {
77
- repoSafety,
78
- shouldBlock: reasons.length > 0,
79
- reasons,
80
- };
70
+ return getRepoMutationSafety();
81
71
  };
82
72
 
83
73
  const resolveSessionId = (sessionId, activeSessionId) => {
@@ -190,6 +180,7 @@ export const smartMetrics = async ({
190
180
  latest = 10,
191
181
  }) => {
192
182
  const resolved = resolveMetricsInput({ file });
183
+ const preflightStorageHealth = resolved.kind === 'sqlite' ? getStateStorageHealth({ filePath: resolved.storagePath }) : null;
193
184
 
194
185
  if (resolved.kind === 'file') {
195
186
  const { entries, invalidLines } = readMetricsEntries(resolved.storagePath);
@@ -200,12 +191,10 @@ export const smartMetrics = async ({
200
191
 
201
192
  const adoption = analyzeAdoption(filteredEntries);
202
193
 
203
- return {
194
+ return attachSafetyMetadata({
204
195
  filePath: resolved.storagePath,
205
196
  storagePath: resolved.storagePath,
206
197
  source: resolved.source,
207
- repoSafety: null,
208
- sideEffectsSuppressed: false,
209
198
  activeSessionId: null,
210
199
  filters: {
211
200
  tool: tool ?? null,
@@ -216,10 +205,37 @@ export const smartMetrics = async ({
216
205
  invalidLines,
217
206
  summary: aggregateMetrics(filteredEntries),
218
207
  adoption,
208
+ productQuality: analyzeProductQuality(filteredEntries),
219
209
  latestEntries: buildLatestEntries(filteredEntries, latest),
220
- };
210
+ }, {
211
+ repoSafety: null,
212
+ sideEffectsSuppressed: false,
213
+ subject: 'Project-local metrics writes',
214
+ });
221
215
  }
222
216
 
217
+ const sqliteResult = await (async () => {
218
+ try {
219
+ return await readSqliteMetricsEntries({
220
+ tool,
221
+ sessionId,
222
+ window,
223
+ });
224
+ } catch (error) {
225
+ const storageHealth = error.storageHealth ?? await diagnoseStateStorage({ filePath: resolved.storagePath });
226
+ return {
227
+ entries: [],
228
+ activeSessionId: null,
229
+ resolvedSessionId: resolveSessionId(sessionId, null),
230
+ invalidLines: [],
231
+ repoSafety: getRepoSafety(),
232
+ sideEffectsSuppressed: false,
233
+ error,
234
+ storageHealth,
235
+ };
236
+ }
237
+ })();
238
+
223
239
  const {
224
240
  entries,
225
241
  activeSessionId,
@@ -227,20 +243,16 @@ export const smartMetrics = async ({
227
243
  invalidLines,
228
244
  repoSafety,
229
245
  sideEffectsSuppressed,
230
- } = await readSqliteMetricsEntries({
231
- tool,
232
- sessionId,
233
- window,
234
- });
246
+ error,
247
+ storageHealth,
248
+ } = sqliteResult;
235
249
 
236
250
  const adoption = analyzeAdoption(entries);
237
-
238
- return {
251
+
252
+ return attachSafetyMetadata({
239
253
  filePath: resolved.storagePath,
240
254
  storagePath: resolved.storagePath,
241
255
  source: resolved.source,
242
- repoSafety,
243
- sideEffectsSuppressed,
244
256
  activeSessionId,
245
257
  filters: {
246
258
  tool: tool ?? null,
@@ -251,6 +263,18 @@ export const smartMetrics = async ({
251
263
  invalidLines,
252
264
  summary: aggregateMetrics(entries),
253
265
  adoption,
266
+ productQuality: analyzeProductQuality(entries),
254
267
  latestEntries: buildLatestEntries(entries, latest),
255
- };
268
+ storageHealth: storageHealth ?? (sideEffectsSuppressed
269
+ ? await diagnoseStateStorage({ filePath: resolved.storagePath })
270
+ : (preflightStorageHealth ?? null)),
271
+ ...(error ? { error: error.message } : {}),
272
+ }, {
273
+ repoSafety,
274
+ sideEffectsSuppressed,
275
+ subject: 'Project-local metrics writes',
276
+ degradedReason: 'repo_safety_blocked',
277
+ degradedMode: 'snapshot_metrics_read',
278
+ degradedImpact: 'Metrics writes and maintenance side effects are paused while git hygiene is blocked.',
279
+ });
256
280
  };
@@ -1,4 +1,12 @@
1
- import { withStateDb } from '../storage/sqlite.js';
1
+ import { getRepoMutationSafety } from '../repo-safety.js';
2
+ import {
3
+ diagnoseStateStorage,
4
+ getStateStorageHealth,
5
+ importLegacyState,
6
+ withStateDb,
7
+ withStateDbSnapshot,
8
+ } from '../storage/sqlite.js';
9
+ import { attachSafetyMetadata } from '../utils/mutation-safety.js';
2
10
  import { recordToolUsage } from '../usage-feedback.js';
3
11
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
4
12
  import { recordDevctxOperation } from '../missed-opportunities.js';
@@ -7,7 +15,15 @@ import { countTokens } from '../tokenCounter.js';
7
15
  const ACTIVE_SESSION_SCOPE = 'active';
8
16
 
9
17
  const getActiveSession = async () => {
10
- return withStateDb((db) => {
18
+ const mutationSafety = getRepoMutationSafety();
19
+ const allowReadSideEffects = !mutationSafety.shouldBlock;
20
+ const reader = allowReadSideEffects ? withStateDb : withStateDbSnapshot;
21
+
22
+ if (allowReadSideEffects) {
23
+ await importLegacyState();
24
+ }
25
+
26
+ const session = await reader((db) => {
11
27
  let activeSessionId = db.prepare(`
12
28
  SELECT session_id
13
29
  FROM active_session
@@ -63,7 +79,13 @@ const getActiveSession = async () => {
63
79
  touchedFilesCount: row.touched_files_count || 0,
64
80
  updatedAt: row.updated_at,
65
81
  };
66
- });
82
+ }, allowReadSideEffects ? undefined : {});
83
+
84
+ return {
85
+ session,
86
+ repoSafety: mutationSafety.repoSafety,
87
+ sideEffectsSuppressed: !allowReadSideEffects,
88
+ };
67
89
  };
68
90
 
69
91
  const formatContextItem = (item, index, total) => {
@@ -89,6 +111,7 @@ const compactPath = (filePath) => {
89
111
 
90
112
  export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) => {
91
113
  const startTime = Date.now();
114
+ const preflightStorageHealth = getStateStorageHealth();
92
115
 
93
116
  recordDecision({
94
117
  tool: 'smart_status',
@@ -98,14 +121,54 @@ export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) =
98
121
 
99
122
  recordDevctxOperation('smart_status');
100
123
 
101
- const session = await getActiveSession();
124
+ let sessionResult;
125
+ try {
126
+ sessionResult = await getActiveSession();
127
+ } catch (error) {
128
+ const storageHealth = error.storageHealth ?? await diagnoseStateStorage();
129
+ const response = attachSafetyMetadata({
130
+ success: false,
131
+ message: storageHealth.message,
132
+ hint: storageHealth.recommendedActions?.[0] ?? 'Inspect .devctx/state.sqlite before retrying.',
133
+ storageHealth,
134
+ error: error.message,
135
+ }, {
136
+ repoSafety: getRepoMutationSafety().repoSafety,
137
+ sideEffectsSuppressed: false,
138
+ subject: 'Project-local context writes',
139
+ degradedReason: 'storage_unavailable',
140
+ degradedMode: 'storage_error',
141
+ degradedImpact: 'Persistent session state could not be opened.',
142
+ });
143
+
144
+ recordToolUsage({
145
+ tool: 'smart_status',
146
+ rawTokens: 0,
147
+ compressedTokens: countTokens(JSON.stringify(response)),
148
+ savedTokens: 0,
149
+ savingsPct: 0,
150
+ });
151
+
152
+ return response;
153
+ }
154
+
155
+ const { session, repoSafety, sideEffectsSuppressed } = sessionResult;
156
+ const storageHealth = sideEffectsSuppressed ? await diagnoseStateStorage() : preflightStorageHealth;
102
157
 
103
158
  if (!session) {
104
- const response = {
159
+ const response = attachSafetyMetadata({
105
160
  success: false,
106
161
  message: 'No active session found',
107
162
  hint: 'Use smart_summary with action=update to create a session',
108
- };
163
+ storageHealth,
164
+ }, {
165
+ repoSafety,
166
+ sideEffectsSuppressed,
167
+ subject: 'Project-local context writes',
168
+ degradedReason: 'repo_safety_blocked',
169
+ degradedMode: 'read_only_snapshot',
170
+ degradedImpact: 'Session-maintenance side effects are paused while git hygiene is blocked.',
171
+ });
109
172
 
110
173
  recordToolUsage({
111
174
  tool: 'smart_status',
@@ -135,6 +198,7 @@ export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) =
135
198
  files: session.touchedFilesCount,
136
199
  },
137
200
  recentFiles: recentFiles.slice(-3),
201
+ storageHealth,
138
202
  updatedAt: session.updatedAt,
139
203
  };
140
204
  } else {
@@ -182,6 +246,7 @@ export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) =
182
246
  pinned: session.pinnedContext,
183
247
  questions: session.unresolvedQuestions,
184
248
  },
249
+ storageHealth,
185
250
  updatedAt: session.updatedAt,
186
251
  };
187
252
  }
@@ -197,5 +262,12 @@ export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) =
197
262
  savingsPct: 0,
198
263
  });
199
264
 
200
- return output;
265
+ return attachSafetyMetadata(output, {
266
+ repoSafety,
267
+ sideEffectsSuppressed,
268
+ subject: 'Project-local context writes',
269
+ degradedReason: 'repo_safety_blocked',
270
+ degradedMode: 'read_only_snapshot',
271
+ degradedImpact: 'Session-maintenance side effects are paused while git hygiene is blocked.',
272
+ });
201
273
  };