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.
- package/README.md +129 -1
- package/package.json +12 -3
- package/scripts/doctor-state.js +89 -0
- package/scripts/init-clients.js +48 -7
- package/scripts/report-adoption-metrics.js +228 -0
- package/scripts/report-metrics.js +12 -1
- package/scripts/report-workflow-metrics.js +35 -2
- package/scripts/task-runner.js +238 -0
- package/src/analytics/product-quality.js +206 -0
- package/src/client-contract.js +153 -0
- package/src/context-patterns.js +11 -0
- package/src/hooks/claude-hooks.js +48 -49
- package/src/metrics.js +19 -16
- package/src/orchestration/headless-wrapper.js +20 -26
- package/src/repo-safety.js +21 -0
- package/src/server.js +14 -4
- package/src/storage/sqlite.js +241 -12
- package/src/task-runner/policy.js +354 -0
- package/src/task-runner.js +631 -0
- package/src/tools/smart-doctor.js +418 -0
- package/src/tools/smart-metrics.js +54 -30
- package/src/tools/smart-status.js +79 -7
- package/src/tools/smart-summary.js +29 -29
- package/src/tools/smart-turn.js +455 -14
- package/src/utils/mutation-safety.js +85 -0
- package/src/workflow-tracker.js +261 -39
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|