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,4 +1,6 @@
1
1
  import { persistMetrics } from '../metrics.js';
2
+ import { buildOperationalContextLines } from '../client-contract.js';
3
+ import { getRepoMutationSafety } from '../repo-safety.js';
2
4
  import { countTokens } from '../tokenCounter.js';
3
5
  import { smartSummary } from '../tools/smart-summary.js';
4
6
  import { smartTurn } from '../tools/smart-turn.js';
@@ -11,7 +13,7 @@ import {
11
13
  const HOOK_CLIENT = 'claude';
12
14
  const START_MAX_TOKENS = 350;
13
15
  const STOP_MAX_TOKENS = 300;
14
- const MAX_CONTEXT_LINES = 5;
16
+ const MAX_CONTEXT_LINES = 7;
15
17
  const MAX_CONTEXT_CHARS = 420;
16
18
  const MAX_PROMPT_PREVIEW = 160;
17
19
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
@@ -50,48 +52,12 @@ const buildHookKey = ({ sessionId, agentId = null }) =>
50
52
  agentId ? `${HOOK_CLIENT}:subagent:${sessionId}:${agentId}` : `${HOOK_CLIENT}:main:${sessionId}`;
51
53
 
52
54
  const buildAdditionalContext = ({ result, sessionStart = false }) => {
53
- const lines = [];
54
- const repoSafety = result?.repoSafety;
55
- const summary = result?.summary;
56
- const continuityState = result?.continuity?.state;
57
-
58
- if (result?.found && summary) {
59
- const label = sessionStart ? 'resume' : continuityState ?? 'resume';
60
- lines.push(`devctx ${label}: session ${result.sessionId}`);
61
-
62
- if (summary.goal) {
63
- lines.push(`goal: ${truncate(summary.goal, 110)}`);
64
- }
65
-
66
- if (summary.currentFocus) {
67
- lines.push(`focus: ${truncate(summary.currentFocus, 110)}`);
68
- }
69
-
70
- if (summary.nextStep) {
71
- lines.push(`next: ${truncate(summary.nextStep, 110)}`);
72
- }
73
- } else if (result?.continuity?.state === 'ambiguous_resume') {
74
- lines.push('devctx: multiple persisted sessions matched this prompt.');
75
- if (result?.recommendedSessionId) {
76
- lines.push(`recommended session: ${result.recommendedSessionId}`);
77
- }
78
- } else if (result?.autoCreated && summary?.goal) {
79
- lines.push(`devctx new task session: ${truncate(summary.goal, 110)}`);
80
- }
81
-
82
- if (repoSafety?.isTracked || repoSafety?.isStaged) {
83
- const reasons = [];
84
- if (repoSafety.isTracked) {
85
- reasons.push('tracked');
86
- }
87
- if (repoSafety.isStaged) {
88
- reasons.push('staged');
89
- }
90
- lines.push(`repo safety: .devctx/state.sqlite is ${reasons.join(' and ')}; context writes are blocked.`);
91
- }
92
-
93
- const clipped = lines.slice(0, MAX_CONTEXT_LINES).join('\n').slice(0, MAX_CONTEXT_CHARS).trim();
94
- return clipped || null;
55
+ return buildOperationalContextLines(result, {
56
+ sessionStart,
57
+ maxLineLength: 110,
58
+ maxLines: MAX_CONTEXT_LINES,
59
+ maxChars: MAX_CONTEXT_CHARS,
60
+ });
95
61
  };
96
62
 
97
63
  const buildHookContextResponse = (hookEventName, additionalContext) => {
@@ -107,6 +73,28 @@ const buildHookContextResponse = (hookEventName, additionalContext) => {
107
73
  };
108
74
  };
109
75
 
76
+ const readHookTurnState = (hookKey) =>
77
+ getHookTurnState({
78
+ hookKey,
79
+ readOnly: getRepoMutationSafety().shouldBlock,
80
+ });
81
+
82
+ const maybeSetTrackedTurnState = async ({ hookKey, state }) => {
83
+ if (getRepoMutationSafety().shouldBlock) {
84
+ return null;
85
+ }
86
+
87
+ return setHookTurnState({ hookKey, state });
88
+ };
89
+
90
+ const maybeDeleteTrackedTurnState = async ({ hookKey }) => {
91
+ if (getRepoMutationSafety().shouldBlock) {
92
+ return null;
93
+ }
94
+
95
+ return deleteHookTurnState({ hookKey });
96
+ };
97
+
110
98
  const recordHookMetrics = async ({
111
99
  action,
112
100
  sessionId,
@@ -247,7 +235,7 @@ const maybeTrackTurn = async ({
247
235
  return null;
248
236
  }
249
237
 
250
- return setHookTurnState({
238
+ return maybeSetTrackedTurnState({
251
239
  hookKey,
252
240
  state: {
253
241
  client: HOOK_CLIENT,
@@ -308,7 +296,7 @@ const handleUserPromptSubmit = async (input) => {
308
296
 
309
297
  const handlePostToolUse = async (input) => {
310
298
  const hookKey = buildHookKey({ sessionId: input.session_id });
311
- const existing = await getHookTurnState({ hookKey });
299
+ const existing = await readHookTurnState(hookKey);
312
300
  if (!existing) {
313
301
  return null;
314
302
  }
@@ -332,7 +320,7 @@ const handlePostToolUse = async (input) => {
332
320
  updatedAt: new Date().toISOString(),
333
321
  };
334
322
 
335
- await setHookTurnState({ hookKey, state: nextState });
323
+ await maybeSetTrackedTurnState({ hookKey, state: nextState });
336
324
  if (checkpoint.matched || touchedFiles.length > 0) {
337
325
  await recordHookMetrics({
338
326
  action: 'PostToolUse',
@@ -346,11 +334,22 @@ const handlePostToolUse = async (input) => {
346
334
 
347
335
  const handleStop = async (input) => {
348
336
  const hookKey = buildHookKey({ sessionId: input.session_id });
349
- const state = await getHookTurnState({ hookKey });
337
+ const state = await readHookTurnState(hookKey);
350
338
  if (!state) {
351
339
  return null;
352
340
  }
353
341
 
342
+ if (getRepoMutationSafety().shouldBlock) {
343
+ await recordHookMetrics({
344
+ action: 'Stop',
345
+ sessionId: state.projectSessionId,
346
+ additionalContext: '',
347
+ blocked: false,
348
+ continuityState: state.continuityState,
349
+ });
350
+ return null;
351
+ }
352
+
354
353
  const enforcement = computeStopEnforcement(state, input.last_assistant_message);
355
354
  const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
356
355
  if (!shouldEnforce || state.checkpointed) {
@@ -361,7 +360,7 @@ const handleStop = async (input) => {
361
360
  blocked: false,
362
361
  continuityState: state.continuityState,
363
362
  });
364
- await deleteHookTurnState({ hookKey });
363
+ await maybeDeleteTrackedTurnState({ hookKey });
365
364
  return null;
366
365
  }
367
366
 
@@ -384,7 +383,7 @@ const handleStop = async (input) => {
384
383
  autoAppended: true,
385
384
  continuityState: state.continuityState,
386
385
  });
387
- await deleteHookTurnState({ hookKey });
386
+ await maybeDeleteTrackedTurnState({ hookKey });
388
387
  return null;
389
388
  }
390
389
 
package/src/metrics.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import fsSync from 'node:fs';
3
3
  import path from 'node:path';
4
- import { enforceRepoSafety } from './repo-safety.js';
4
+ import { getRepoMutationSafety } from './repo-safety.js';
5
5
  import { countTokens } from './tokenCounter.js';
6
6
  import { projectRoot } from './utils/paths.js';
7
7
  import {
@@ -16,11 +16,6 @@ import {
16
16
  const defaultMetricsDir = () => path.join(projectRoot, '.devctx');
17
17
  const defaultMetricsFile = () => path.join(defaultMetricsDir(), 'metrics.jsonl');
18
18
  const resolveEnvMetricsFile = () => process.env.DEVCTX_METRICS_FILE?.trim() || null;
19
- const HARD_BLOCK_REPO_SAFETY_REASONS = [
20
- ['tracked', 'isTracked'],
21
- ['staged', 'isStaged'],
22
- ];
23
-
24
19
  export const getMetricsFilePath = () => resolveEnvMetricsFile() ?? defaultMetricsFile();
25
20
 
26
21
  let lastEnsuredDir = null;
@@ -68,6 +63,9 @@ export const getEntrySavingsPct = (
68
63
  return rawTokens > 0 ? Number(((savedTokens / rawTokens) * 100).toFixed(2)) : 0;
69
64
  };
70
65
 
66
+ export const getNetSavedTokens = (savedTokens, overheadTokens = 0) =>
67
+ Math.max(0, Number(savedTokens ?? 0) - Math.max(0, Number(overheadTokens ?? 0)));
68
+
71
69
  export const MAX_METRICS_BYTES = 1024 * 1024;
72
70
  export const KEEP_LINES_AFTER_ROTATION = 500;
73
71
 
@@ -89,16 +87,7 @@ const readActiveSessionIdFromDb = (db) =>
89
87
  db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
90
88
 
91
89
  const getSqliteSafetyPolicy = () => {
92
- const repoSafety = enforceRepoSafety();
93
- const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
94
- .filter(([, field]) => repoSafety[field])
95
- .map(([reason]) => reason);
96
-
97
- return {
98
- repoSafety,
99
- shouldBlock: reasons.length > 0,
100
- reasons,
101
- };
90
+ return getRepoMutationSafety();
102
91
  };
103
92
 
104
93
  export const getActiveSessionId = async () => {
@@ -258,20 +247,34 @@ export const aggregateMetrics = (entries) => {
258
247
 
259
248
  const tools = [...byTool.values()]
260
249
  .map((item) => ({
250
+ constOverhead: overheadByTool.get(item.tool)?.overheadTokens ?? 0,
261
251
  ...item,
252
+ }))
253
+ .map((item) => ({
254
+ ...item,
255
+ overheadTokens: item.constOverhead,
256
+ netSavedTokens: getNetSavedTokens(item.savedTokens, item.constOverhead),
262
257
  savingsPct: item.rawTokens > 0 ? Number(((item.savedTokens / item.rawTokens) * 100).toFixed(2)) : 0,
258
+ netSavingsPct: item.rawTokens > 0
259
+ ? Number(((getNetSavedTokens(item.savedTokens, item.constOverhead) / item.rawTokens) * 100).toFixed(2))
260
+ : 0,
263
261
  }))
262
+ .map(({ constOverhead, ...item }) => item)
264
263
  .sort((a, b) => b.savedTokens - a.savedTokens || b.count - a.count || a.tool.localeCompare(b.tool));
265
264
 
266
265
  const overheadTools = [...overheadByTool.values()]
267
266
  .sort((a, b) => b.overheadTokens - a.overheadTokens || b.count - a.count || a.tool.localeCompare(b.tool));
268
267
 
268
+ const netSavedTokens = getNetSavedTokens(savedTokens, overheadTokens);
269
+
269
270
  return {
270
271
  count: entries.length,
271
272
  rawTokens,
272
273
  compressedTokens,
273
274
  savedTokens,
274
275
  savingsPct: rawTokens > 0 ? Number(((savedTokens / rawTokens) * 100).toFixed(2)) : 0,
276
+ netSavedTokens,
277
+ netSavingsPct: rawTokens > 0 ? Number(((netSavedTokens / rawTokens) * 100).toFixed(2)) : 0,
275
278
  tools,
276
279
  overheadTokens,
277
280
  overheadPctOfRaw: rawTokens > 0 ? Number(((overheadTokens / rawTokens) * 100).toFixed(2)) : 0,
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { persistMetrics } from '../metrics.js';
3
3
  import { countTokens } from '../tokenCounter.js';
4
+ import { buildOperationalContextLines } from '../client-contract.js';
4
5
  import { smartSummary } from '../tools/smart-summary.js';
5
6
  import { smartTurn } from '../tools/smart-turn.js';
6
7
 
@@ -39,30 +40,13 @@ const extractNextStep = (value) => {
39
40
  };
40
41
 
41
42
  const buildContextLines = (startResult) => {
42
- const summary = startResult?.summary ?? {};
43
- const lines = [];
44
-
45
- if (startResult?.sessionId) {
46
- lines.push(`Persisted devctx session: ${startResult.sessionId}`);
47
- }
48
-
49
- if (summary.goal) {
50
- lines.push(`Goal: ${truncate(summary.goal, 120)}`);
51
- }
52
-
53
- if (summary.currentFocus) {
54
- lines.push(`Focus: ${truncate(summary.currentFocus, 120)}`);
55
- }
56
-
57
- if (summary.nextStep) {
58
- lines.push(`Next step: ${truncate(summary.nextStep, 120)}`);
59
- }
60
-
61
- if (startResult?.continuity?.reason) {
62
- lines.push(`Context status: ${truncate(startResult.continuity.reason, 120)}`);
63
- }
64
-
65
- return lines.slice(0, 5);
43
+ const context = buildOperationalContextLines(startResult, {
44
+ sessionStart: false,
45
+ maxLineLength: 120,
46
+ maxLines: 8,
47
+ maxChars: 560,
48
+ });
49
+ return context ? context.split('\n') : [];
66
50
  };
67
51
 
68
52
  export const buildWrappedPrompt = ({ prompt, startResult }) => {
@@ -95,7 +79,16 @@ const ensureIsolatedSession = async ({ prompt, sessionId, startResult }) => {
95
79
  if (sessionId || !startResult?.sessionId) {
96
80
  return {
97
81
  startResult,
98
- isolated: false,
82
+ isolated: Boolean(startResult?.isolatedSession),
83
+ previousSessionId: startResult?.previousSessionId ?? null,
84
+ };
85
+ }
86
+
87
+ if (startResult?.isolatedSession) {
88
+ return {
89
+ startResult,
90
+ isolated: true,
91
+ previousSessionId: startResult.previousSessionId ?? null,
99
92
  };
100
93
  }
101
94
 
@@ -200,6 +193,7 @@ export const runHeadlessWrapper = async ({
200
193
  dryRun = false,
201
194
  streamOutput = false,
202
195
  runCommand = runChildProcess,
196
+ preparedStartResult = null,
203
197
  } = {}) => {
204
198
  if (!normalizeWhitespace(prompt)) {
205
199
  throw new Error('prompt is required');
@@ -209,7 +203,7 @@ export const runHeadlessWrapper = async ({
209
203
  throw new Error('command is required unless dryRun=true');
210
204
  }
211
205
 
212
- const start = await smartTurn({
206
+ const start = preparedStartResult ?? await smartTurn({
213
207
  phase: 'start',
214
208
  sessionId,
215
209
  prompt,
@@ -4,6 +4,11 @@ import { execFileSync } from 'node:child_process';
4
4
  import { getStateDbPath } from './storage/sqlite.js';
5
5
  import { projectRoot } from './utils/runtime-config.js';
6
6
 
7
+ export const HARD_BLOCK_REPO_SAFETY_REASONS = Object.freeze([
8
+ ['tracked', 'isTracked'],
9
+ ['staged', 'isStaged'],
10
+ ]);
11
+
7
12
  const hasGitignoreEntry = (content, entry) => {
8
13
  const target = entry.replace(/\/+$/, '');
9
14
  return content
@@ -164,3 +169,19 @@ export const enforceRepoSafety = ({
164
169
  : 'Repository safety checks failed.',
165
170
  };
166
171
  };
172
+
173
+ export const getRepoMutationSafety = ({
174
+ root = projectRoot,
175
+ stateDbPath = getStateDbPath(),
176
+ } = {}) => {
177
+ const repoSafety = enforceRepoSafety({ root, stateDbPath });
178
+ const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
179
+ .filter(([, field]) => repoSafety[field])
180
+ .map(([reason]) => reason);
181
+
182
+ return {
183
+ repoSafety,
184
+ shouldBlock: reasons.length > 0,
185
+ reasons,
186
+ };
187
+ };
package/src/server.js CHANGED
@@ -10,6 +10,7 @@ import { smartReadBatch } from './tools/smart-read-batch.js';
10
10
  import { smartShell } from './tools/smart-shell.js';
11
11
  import { smartSummary } from './tools/smart-summary.js';
12
12
  import { smartStatus } from './tools/smart-status.js';
13
+ import { smartDoctor } from './tools/smart-doctor.js';
13
14
  import { smartEdit } from './tools/smart-edit.js';
14
15
  import { smartMetrics } from './tools/smart-metrics.js';
15
16
  import { smartTurn } from './tools/smart-turn.js';
@@ -379,7 +380,7 @@ This ensures optimal performance and context recovery.`,
379
380
 
380
381
  server.tool(
381
382
  'smart_summary',
382
- 'Maintain compressed conversation state across turns. Actions: get (retrieve current/last session), update (create or replace a session; omitted fields are cleared), append (add to existing session), auto_append (append only if something meaningful changed), checkpoint (event-driven orchestration that decides whether to auto-persist), reset (clear session), list_sessions (show all sessions), compact (apply retention/compaction to SQLite events), cleanup_legacy (inspect or remove imported legacy JSON/JSONL files). Sessions persist in project-local SQLite with 30-day retention defaults. Auto-generates sessionId from goal if omitted. `get` auto-resumes the active session when present, otherwise falls back to the best saved session when unambiguous; pass `sessionId: "auto"` to accept the recommended session even when multiple recent candidates exist. Returns a resume summary capped at maxTokens (default 500) plus compression metadata (`truncated`, `compressionLevel`, `omitted`) and `schemaVersion`. Includes `repoSafety` so agents can catch `.devctx/state.sqlite` git hygiene issues early; mutating actions are blocked at runtime when that SQLite file is tracked or staged. Tracks: goal, status, pinned context, unresolved questions, current focus, blockers, next step, completed steps, key decisions, and touched files.',
383
+ 'Maintain compressed conversation state across turns. Actions: get (retrieve current/last session), update (create or replace a session; omitted fields are cleared), append (add to existing session), auto_append (append only if something meaningful changed), checkpoint (event-driven orchestration that decides whether to auto-persist), reset (clear session), list_sessions (show all sessions), compact (apply retention/compaction to SQLite events), cleanup_legacy (inspect or remove imported legacy JSON/JSONL files). Sessions persist in project-local SQLite with 30-day retention defaults. Auto-generates sessionId from goal if omitted. `get` auto-resumes the active session when present, otherwise falls back to the best saved session when unambiguous; pass `sessionId: "auto"` to accept the recommended session even when multiple recent candidates exist. Returns a resume summary capped at maxTokens (default 500) plus compression metadata (`truncated`, `compressionLevel`, `omitted`) and `schemaVersion`. Exposes `repoSafety`, `mutationSafety`, `degradedMode`, and `storageHealth` so agents can detect blocked, read-only, missing, oversized, locked, or corrupted local state. Tracks: goal, status, pinned context, unresolved questions, current focus, blockers, next step, completed steps, key decisions, and touched files.',
383
384
  {
384
385
  action: z.enum(['get', 'update', 'append', 'auto_append', 'checkpoint', 'reset', 'list_sessions', 'compact', 'cleanup_legacy']),
385
386
  sessionId: z.string().optional(),
@@ -445,7 +446,7 @@ This ensures optimal performance and context recovery.`,
445
446
 
446
447
  server.tool(
447
448
  'smart_status',
448
- 'Display the current session context including goal, status, recent decisions, touched files, and progress. Returns a formatted summary of what has been done and what is being tracked in the active session. Use this to understand the current state of work without modifying the session. Supports format=detailed (default, full formatted output) or format=compact (minimal JSON). Optional maxItems limits how many recent items to show (default 10).',
449
+ 'Display the current session context including goal, status, recent decisions, touched files, and progress. Returns a formatted summary of what has been done and what is being tracked in the active session. Use this to understand the current state of work without modifying the session. Supports format=detailed (default, full formatted output) or format=compact (minimal JSON). Optional maxItems limits how many recent items to show (default 10). Exposes `mutationSafety`, `repoSafety`, `degradedMode`, and `storageHealth` when repo-safety or SQLite storage health affects session reads.',
449
450
  {
450
451
  format: z.enum(['detailed', 'compact']).optional(),
451
452
  maxItems: z.number().int().min(1).max(50).optional(),
@@ -453,6 +454,15 @@ This ensures optimal performance and context recovery.`,
453
454
  async ({ format, maxItems }) => asTextResult(await smartStatus({ format, maxItems })),
454
455
  );
455
456
 
457
+ server.tool(
458
+ 'smart_doctor',
459
+ 'Run an operational health check for local devctx state. Aggregates repo hygiene, SQLite `storageHealth`, retention/compaction hygiene, and legacy JSON/JSONL cleanup guidance into one inspect-only result with explicit remediation steps. Use this when `.devctx/state.sqlite` is missing, oversized, locked, corrupted, or when you want a release/preflight check for local state durability.',
460
+ {
461
+ verifyIntegrity: z.boolean().optional(),
462
+ },
463
+ async ({ verifyIntegrity }) => asTextResult(await smartDoctor({ verifyIntegrity })),
464
+ );
465
+
456
466
  server.tool(
457
467
  'smart_edit',
458
468
  'Batch edit multiple files with pattern replacement. Supports literal string replacement or regex patterns. Use for bulk refactoring, removing patterns (comments, console.log, etc.), or renaming across files. Optional dryRun shows preview without modifying files. Returns match count and results per file.',
@@ -469,7 +479,7 @@ This ensures optimal performance and context recovery.`,
469
479
 
470
480
  server.tool(
471
481
  'smart_turn',
472
- 'Orchestrate start/end of a meaningful agent turn so context usage becomes almost mandatory with low token overhead. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary and can optionally include compact metrics. Use this instead of manually chaining `smart_summary(get)` and `smart_summary(checkpoint)` when you want a single context-first turn workflow.',
482
+ 'Orchestrate start/end of a meaningful agent turn so context usage becomes almost mandatory with low token overhead. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, returns `recommendedPath` guidance for the next safe devctx actions, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary, returns follow-up `recommendedPath` guidance, and can optionally include compact metrics. Both phases expose `mutationSafety` when repo-safety blocks persisted writes and now surface `storageHealth` when SQLite state is missing, oversized, locked, or corrupted. Use this instead of manually chaining `smart_summary(get)` and `smart_summary(checkpoint)` when you want a single context-first turn workflow.',
473
483
  {
474
484
  phase: z.enum(['start', 'end']),
475
485
  sessionId: z.string().optional(),
@@ -513,7 +523,7 @@ This ensures optimal performance and context recovery.`,
513
523
 
514
524
  server.tool(
515
525
  'smart_metrics',
516
- 'Inspect token metrics recorded in project-local SQLite storage by default. Returns aggregated totals, per-tool savings, and recent entries. Supports time windows (`24h`, `7d`, `30d`, `all`), optional tool filtering, and optional session filtering (`sessionId: "active"` resolves the current active session automatically). Pass `file` to inspect a legacy/custom JSONL file explicitly. When `.devctx/state.sqlite` is tracked or staged, reads fall back to a temporary read-only snapshot and report `sideEffectsSuppressed`; metrics writes from other tools are skipped until git hygiene is fixed.',
526
+ 'Inspect token metrics recorded in project-local SQLite storage by default. Returns aggregated totals, per-tool savings, recent entries, adoption analysis, and `productQuality` signals measured from `smart_turn` orchestration events (continuity recovery, blocked-state remediation coverage, context-refresh signals, checkpoint persistence). Supports time windows (`24h`, `7d`, `30d`, `all`), optional tool filtering, and optional session filtering (`sessionId: "active"` resolves the current active session automatically). Pass `file` to inspect a legacy/custom JSONL file explicitly. When `.devctx/state.sqlite` is tracked or staged, reads fall back to a temporary read-only snapshot and expose `repoSafety`, `mutationSafety`, `degradedMode`, `sideEffectsSuppressed`, and `storageHealth`; metrics writes from other tools are skipped until git hygiene is fixed.',
517
527
  {
518
528
  file: z.string().optional(),
519
529
  tool: z.string().optional(),