smart-context-mcp 1.6.2 → 1.7.1
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 +2 -2
- package/package.json +1 -1
- package/scripts/headless-wrapper.js +2 -1
- package/scripts/report-metrics.js +2 -2
- package/scripts/task-runner.js +2 -1
- package/src/analytics/product-quality.js +176 -1
- package/src/hooks/claude-hooks.js +1 -423
- package/src/hooks/cursor-hooks.js +1 -0
- package/src/orchestration/adapters/claude-adapter.js +426 -0
- package/src/orchestration/adapters/cursor-adapter.js +429 -0
- package/src/orchestration/base-orchestrator.js +242 -0
- package/src/orchestration/headless-wrapper.js +39 -195
- package/src/orchestration/policy/event-policy.js +297 -0
- package/src/task-runner.js +33 -247
- package/src/utils/client-detection.js +33 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { persistMetrics } from '../../metrics.js';
|
|
2
|
+
import { buildOperationalContextLines } from '../../client-contract.js';
|
|
3
|
+
import { getRepoMutationSafety } from '../../repo-safety.js';
|
|
4
|
+
import { countTokens } from '../../tokenCounter.js';
|
|
5
|
+
import { smartSummary } from '../../tools/smart-summary.js';
|
|
6
|
+
import { smartTurn } from '../../tools/smart-turn.js';
|
|
7
|
+
import {
|
|
8
|
+
deleteHookTurnState,
|
|
9
|
+
getHookTurnState,
|
|
10
|
+
setHookTurnState,
|
|
11
|
+
} from '../../storage/sqlite.js';
|
|
12
|
+
import { DEFAULT_START_MAX_TOKENS, resolveManagedStart } from '../base-orchestrator.js';
|
|
13
|
+
import { extractNextStep, normalizeWhitespace, truncate } from '../policy/event-policy.js';
|
|
14
|
+
|
|
15
|
+
export const HOOK_CLIENT = 'claude';
|
|
16
|
+
export const STOP_MAX_TOKENS = 300;
|
|
17
|
+
export const MAX_CONTEXT_LINES = 7;
|
|
18
|
+
export const MAX_CONTEXT_CHARS = 420;
|
|
19
|
+
export const MAX_PROMPT_PREVIEW = 160;
|
|
20
|
+
export const MAX_TOUCHED_FILES = 12;
|
|
21
|
+
export const MIN_MEANINGFUL_PROMPT_LENGTH = 20;
|
|
22
|
+
export const MIN_PROMPT_TERMS = 4;
|
|
23
|
+
export const SIGNIFICANT_RESPONSE_LENGTH = 140;
|
|
24
|
+
export const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
25
|
+
|
|
26
|
+
const uniq = (values) => [...new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))];
|
|
27
|
+
|
|
28
|
+
export const buildClaudeHookKey = ({ sessionId, agentId = null }) =>
|
|
29
|
+
agentId ? `${HOOK_CLIENT}:subagent:${sessionId}:${agentId}` : `${HOOK_CLIENT}:main:${sessionId}`;
|
|
30
|
+
|
|
31
|
+
const countPromptTerms = (value) =>
|
|
32
|
+
normalizeWhitespace(value)
|
|
33
|
+
.split(/[^a-z0-9_.-]+/i)
|
|
34
|
+
.map((term) => term.trim())
|
|
35
|
+
.filter((term) => term.length >= 3)
|
|
36
|
+
.length;
|
|
37
|
+
|
|
38
|
+
export const isMeaningfulPrompt = (value) => {
|
|
39
|
+
const normalized = normalizeWhitespace(value);
|
|
40
|
+
return normalized.length >= MIN_MEANINGFUL_PROMPT_LENGTH && countPromptTerms(normalized) >= MIN_PROMPT_TERMS;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const buildClaudeAdditionalContext = ({ result, sessionStart = false }) =>
|
|
44
|
+
buildOperationalContextLines(result, {
|
|
45
|
+
sessionStart,
|
|
46
|
+
maxLineLength: 110,
|
|
47
|
+
maxLines: MAX_CONTEXT_LINES,
|
|
48
|
+
maxChars: MAX_CONTEXT_CHARS,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const buildClaudeHookContextResponse = (hookEventName, additionalContext) => {
|
|
52
|
+
if (!additionalContext) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName,
|
|
59
|
+
additionalContext,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isSmartTurnTool = (toolName) => /^mcp__.+__smart_turn$/i.test(toolName ?? '');
|
|
65
|
+
const isSmartSummaryTool = (toolName) => /^mcp__.+__smart_summary$/i.test(toolName ?? '');
|
|
66
|
+
|
|
67
|
+
export const isCheckpointToolUse = ({ toolName, toolInput }) => {
|
|
68
|
+
if (isSmartTurnTool(toolName)) {
|
|
69
|
+
return toolInput?.phase === 'end'
|
|
70
|
+
? { matched: true, event: toolInput?.event ?? 'manual' }
|
|
71
|
+
: { matched: false, event: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isSmartSummaryTool(toolName)) {
|
|
75
|
+
const action = toolInput?.action;
|
|
76
|
+
if (action === 'checkpoint') {
|
|
77
|
+
return { matched: true, event: toolInput?.event ?? 'manual' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (action === 'append' || action === 'auto_append' || action === 'update') {
|
|
81
|
+
return { matched: true, event: action };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { matched: false, event: null };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const extractTouchedFilesFromToolUse = ({ toolName, toolInput, toolResponse }) => {
|
|
89
|
+
if (!WRITE_TOOLS.has(toolName)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return uniq([
|
|
94
|
+
toolInput?.file_path,
|
|
95
|
+
toolInput?.filePath,
|
|
96
|
+
toolResponse?.file_path,
|
|
97
|
+
toolResponse?.filePath,
|
|
98
|
+
]).slice(0, MAX_TOUCHED_FILES);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const buildCarryoverUpdate = (state, lastAssistantMessage) => {
|
|
102
|
+
const promptPreview = truncate(state.promptPreview, 140);
|
|
103
|
+
const nextStep = extractNextStep(lastAssistantMessage);
|
|
104
|
+
const pinnedContext = promptPreview ? [`Uncheckpointed turn: ${promptPreview}`] : [];
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...(promptPreview ? { currentFocus: promptPreview } : {}),
|
|
108
|
+
...(pinnedContext.length > 0 ? { pinnedContext } : {}),
|
|
109
|
+
...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
|
|
110
|
+
...(nextStep ? { nextStep } : {}),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const computeStopEnforcement = (state, lastAssistantMessage) => {
|
|
115
|
+
const nextStep = extractNextStep(lastAssistantMessage);
|
|
116
|
+
const responseLength = normalizeWhitespace(lastAssistantMessage).length;
|
|
117
|
+
let score = 0;
|
|
118
|
+
|
|
119
|
+
if (state.meaningfulWriteCount > 0) {
|
|
120
|
+
score += 3;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (state.touchedFiles.length > 0) {
|
|
124
|
+
score += 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (nextStep) {
|
|
128
|
+
score += 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (responseLength >= SIGNIFICANT_RESPONSE_LENGTH) {
|
|
132
|
+
score += 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (state.continuityState === 'task_switch' || state.continuityState === 'possible_shift') {
|
|
136
|
+
score += 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
shouldBlock: score >= 3,
|
|
141
|
+
score,
|
|
142
|
+
nextStep,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const createClaudeAdapter = ({
|
|
147
|
+
startTurn = smartTurn,
|
|
148
|
+
summaryTool = smartSummary,
|
|
149
|
+
resolveStart = resolveManagedStart,
|
|
150
|
+
persistMetric = persistMetrics,
|
|
151
|
+
getMutationSafety = getRepoMutationSafety,
|
|
152
|
+
readHookState = null,
|
|
153
|
+
writeHookState = ({ hookKey, state }) => setHookTurnState({ hookKey, state }),
|
|
154
|
+
removeHookState = ({ hookKey }) => deleteHookTurnState({ hookKey }),
|
|
155
|
+
} = {}) => {
|
|
156
|
+
const readTrackedHookState = async (hookKey) => getHookTurnState({
|
|
157
|
+
hookKey,
|
|
158
|
+
readOnly: getMutationSafety().shouldBlock,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const readTrackedState = readHookState ?? readTrackedHookState;
|
|
162
|
+
|
|
163
|
+
const maybeSetTrackedTurnState = async ({ hookKey, state }) => {
|
|
164
|
+
if (getMutationSafety().shouldBlock) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return writeHookState({ hookKey, state });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const maybeDeleteTrackedTurnState = async ({ hookKey }) => {
|
|
172
|
+
if (getMutationSafety().shouldBlock) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return removeHookState({ hookKey });
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const recordHookMetrics = async ({
|
|
180
|
+
action,
|
|
181
|
+
sessionId,
|
|
182
|
+
additionalContext = '',
|
|
183
|
+
blocked = false,
|
|
184
|
+
autoAppended = false,
|
|
185
|
+
continuityState = null,
|
|
186
|
+
} = {}) => {
|
|
187
|
+
const overheadTokens = additionalContext ? countTokens(additionalContext) : 0;
|
|
188
|
+
const autoStartTriggered = action === 'SessionStart' || action === 'UserPromptSubmit';
|
|
189
|
+
const autoCheckpointTriggered = action === 'Stop' && autoAppended;
|
|
190
|
+
|
|
191
|
+
await persistMetric({
|
|
192
|
+
tool: 'claude_hook',
|
|
193
|
+
action,
|
|
194
|
+
sessionId,
|
|
195
|
+
rawTokens: 0,
|
|
196
|
+
compressedTokens: 0,
|
|
197
|
+
savedTokens: 0,
|
|
198
|
+
savingsPct: 0,
|
|
199
|
+
metadata: {
|
|
200
|
+
client: HOOK_CLIENT,
|
|
201
|
+
adapterClient: HOOK_CLIENT,
|
|
202
|
+
managedByClientAdapter: true,
|
|
203
|
+
autoStartTriggered,
|
|
204
|
+
autoCheckpointTriggered,
|
|
205
|
+
isContextOverhead: overheadTokens > 0,
|
|
206
|
+
overheadTokens,
|
|
207
|
+
blocked,
|
|
208
|
+
autoAppended,
|
|
209
|
+
continuityState,
|
|
210
|
+
},
|
|
211
|
+
timestamp: new Date().toISOString(),
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const maybeTrackTurn = async ({
|
|
216
|
+
hookKey,
|
|
217
|
+
claudeSessionId,
|
|
218
|
+
projectSessionId,
|
|
219
|
+
prompt,
|
|
220
|
+
continuityState,
|
|
221
|
+
}) => {
|
|
222
|
+
const promptMeaningful = isMeaningfulPrompt(prompt);
|
|
223
|
+
const shouldTrack = Boolean(projectSessionId) && promptMeaningful;
|
|
224
|
+
|
|
225
|
+
if (!shouldTrack) {
|
|
226
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return maybeSetTrackedTurnState({
|
|
231
|
+
hookKey,
|
|
232
|
+
state: {
|
|
233
|
+
client: HOOK_CLIENT,
|
|
234
|
+
claudeSessionId,
|
|
235
|
+
projectSessionId,
|
|
236
|
+
turnId: `${claudeSessionId}:${Date.now()}`,
|
|
237
|
+
promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW),
|
|
238
|
+
continuityState,
|
|
239
|
+
requireCheckpoint: true,
|
|
240
|
+
promptMeaningful,
|
|
241
|
+
checkpointed: false,
|
|
242
|
+
checkpointEvent: null,
|
|
243
|
+
touchedFiles: [],
|
|
244
|
+
meaningfulWriteCount: 0,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const handleSessionStart = async () => {
|
|
250
|
+
const result = await startTurn({
|
|
251
|
+
phase: 'start',
|
|
252
|
+
maxTokens: DEFAULT_START_MAX_TOKENS,
|
|
253
|
+
});
|
|
254
|
+
const additionalContext = buildClaudeAdditionalContext({ result, sessionStart: true });
|
|
255
|
+
await recordHookMetrics({
|
|
256
|
+
action: 'SessionStart',
|
|
257
|
+
sessionId: result.sessionId ?? null,
|
|
258
|
+
additionalContext,
|
|
259
|
+
continuityState: result.continuity?.state ?? null,
|
|
260
|
+
});
|
|
261
|
+
return buildClaudeHookContextResponse('SessionStart', additionalContext);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const handleUserPromptSubmit = async (input) => {
|
|
265
|
+
const startResolution = await resolveStart({
|
|
266
|
+
prompt: input.prompt,
|
|
267
|
+
ensureSession: true,
|
|
268
|
+
allowIsolation: false,
|
|
269
|
+
startTurn,
|
|
270
|
+
summaryTool,
|
|
271
|
+
startMaxTokens: DEFAULT_START_MAX_TOKENS,
|
|
272
|
+
});
|
|
273
|
+
const result = startResolution.startResult;
|
|
274
|
+
|
|
275
|
+
const trackedState = await maybeTrackTurn({
|
|
276
|
+
hookKey: buildClaudeHookKey({ sessionId: input.session_id }),
|
|
277
|
+
claudeSessionId: input.session_id,
|
|
278
|
+
projectSessionId: result.sessionId ?? null,
|
|
279
|
+
prompt: input.prompt,
|
|
280
|
+
continuityState: result.continuity?.state ?? '',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const additionalContext = buildClaudeAdditionalContext({ result });
|
|
284
|
+
await recordHookMetrics({
|
|
285
|
+
action: 'UserPromptSubmit',
|
|
286
|
+
sessionId: trackedState?.projectSessionId ?? result.sessionId ?? null,
|
|
287
|
+
additionalContext,
|
|
288
|
+
continuityState: result.continuity?.state ?? null,
|
|
289
|
+
});
|
|
290
|
+
return buildClaudeHookContextResponse('UserPromptSubmit', additionalContext);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const handlePostToolUse = async (input) => {
|
|
294
|
+
const hookKey = buildClaudeHookKey({ sessionId: input.session_id });
|
|
295
|
+
const existing = await readTrackedState(hookKey);
|
|
296
|
+
if (!existing) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const checkpoint = isCheckpointToolUse({
|
|
301
|
+
toolName: input.tool_name,
|
|
302
|
+
toolInput: input.tool_input,
|
|
303
|
+
});
|
|
304
|
+
const touchedFiles = extractTouchedFilesFromToolUse({
|
|
305
|
+
toolName: input.tool_name,
|
|
306
|
+
toolInput: input.tool_input,
|
|
307
|
+
toolResponse: input.tool_response,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const nextState = {
|
|
311
|
+
...existing,
|
|
312
|
+
checkpointed: checkpoint.matched ? true : existing.checkpointed,
|
|
313
|
+
checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
|
|
314
|
+
touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]).slice(0, MAX_TOUCHED_FILES),
|
|
315
|
+
meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
|
|
316
|
+
updatedAt: new Date().toISOString(),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
await maybeSetTrackedTurnState({ hookKey, state: nextState });
|
|
320
|
+
if (checkpoint.matched || touchedFiles.length > 0) {
|
|
321
|
+
await recordHookMetrics({
|
|
322
|
+
action: 'PostToolUse',
|
|
323
|
+
sessionId: existing.projectSessionId,
|
|
324
|
+
additionalContext: '',
|
|
325
|
+
continuityState: existing.continuityState,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const handleStop = async (input) => {
|
|
332
|
+
const hookKey = buildClaudeHookKey({ sessionId: input.session_id });
|
|
333
|
+
const state = await readTrackedState(hookKey);
|
|
334
|
+
if (!state) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (getMutationSafety().shouldBlock) {
|
|
339
|
+
await recordHookMetrics({
|
|
340
|
+
action: 'Stop',
|
|
341
|
+
sessionId: state.projectSessionId,
|
|
342
|
+
additionalContext: '',
|
|
343
|
+
blocked: false,
|
|
344
|
+
continuityState: state.continuityState,
|
|
345
|
+
});
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const enforcement = computeStopEnforcement(state, input.last_assistant_message);
|
|
350
|
+
const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
|
|
351
|
+
if (!shouldEnforce || state.checkpointed) {
|
|
352
|
+
await recordHookMetrics({
|
|
353
|
+
action: 'Stop',
|
|
354
|
+
sessionId: state.projectSessionId,
|
|
355
|
+
additionalContext: '',
|
|
356
|
+
blocked: false,
|
|
357
|
+
continuityState: state.continuityState,
|
|
358
|
+
});
|
|
359
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (input.stop_hook_active) {
|
|
364
|
+
const update = buildCarryoverUpdate(state, input.last_assistant_message);
|
|
365
|
+
if (state.projectSessionId && Object.keys(update).length > 0) {
|
|
366
|
+
await summaryTool({
|
|
367
|
+
action: 'auto_append',
|
|
368
|
+
sessionId: state.projectSessionId,
|
|
369
|
+
update,
|
|
370
|
+
maxTokens: STOP_MAX_TOKENS,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await recordHookMetrics({
|
|
375
|
+
action: 'Stop',
|
|
376
|
+
sessionId: state.projectSessionId,
|
|
377
|
+
additionalContext: '',
|
|
378
|
+
blocked: false,
|
|
379
|
+
autoAppended: true,
|
|
380
|
+
continuityState: state.continuityState,
|
|
381
|
+
});
|
|
382
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await recordHookMetrics({
|
|
387
|
+
action: 'Stop',
|
|
388
|
+
sessionId: state.projectSessionId,
|
|
389
|
+
additionalContext: '',
|
|
390
|
+
blocked: true,
|
|
391
|
+
continuityState: state.continuityState,
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
decision: 'block',
|
|
395
|
+
reason: `Persist this turn with mcp__devctx__smart_turn phase=end before stopping.${state.touchedFiles.length > 0 ? ' Include touchedFiles and the nextStep.' : ' Include the nextStep.'}`,
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const handleEvent = async (input = {}) => {
|
|
400
|
+
const eventName = input.hook_event_name;
|
|
401
|
+
|
|
402
|
+
if (eventName === 'SessionStart') {
|
|
403
|
+
return handleSessionStart(input);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (eventName === 'UserPromptSubmit') {
|
|
407
|
+
return handleUserPromptSubmit(input);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (eventName === 'PostToolUse') {
|
|
411
|
+
return handlePostToolUse(input);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (eventName === 'Stop') {
|
|
415
|
+
return handleStop(input);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return null;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
handleEvent,
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
export const handleClaudeHookEvent = createClaudeAdapter().handleEvent;
|