grov 0.1.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/LICENSE +190 -0
- package/README.md +211 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +106 -0
- package/dist/commands/capture.d.ts +6 -0
- package/dist/commands/capture.js +324 -0
- package/dist/commands/drift-test.d.ts +7 -0
- package/dist/commands/drift-test.js +177 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/inject.d.ts +5 -0
- package/dist/commands/inject.js +88 -0
- package/dist/commands/prompt-inject.d.ts +4 -0
- package/dist/commands/prompt-inject.js +451 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/unregister.d.ts +1 -0
- package/dist/commands/unregister.js +22 -0
- package/dist/lib/anchor-extractor.d.ts +30 -0
- package/dist/lib/anchor-extractor.js +296 -0
- package/dist/lib/correction-builder.d.ts +10 -0
- package/dist/lib/correction-builder.js +226 -0
- package/dist/lib/debug.d.ts +24 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/drift-checker.d.ts +66 -0
- package/dist/lib/drift-checker.js +341 -0
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +258 -0
- package/dist/lib/jsonl-parser.d.ts +87 -0
- package/dist/lib/jsonl-parser.js +281 -0
- package/dist/lib/llm-extractor.d.ts +50 -0
- package/dist/lib/llm-extractor.js +408 -0
- package/dist/lib/session-parser.d.ts +44 -0
- package/dist/lib/session-parser.js +256 -0
- package/dist/lib/store.d.ts +248 -0
- package/dist/lib/store.js +793 -0
- package/dist/lib/utils.d.ts +31 -0
- package/dist/lib/utils.js +76 -0
- package/package.json +67 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// grov capture - Called by Stop hook, extracts and stores reasoning
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import { findLatestSessionFile, parseSession, getSessionIdFromPath, isPathWithinProject } from '../lib/jsonl-parser.js';
|
|
4
|
+
import { createTask, createFileReasoning, getSessionState, updateSessionState, shouldFlagForReview, getDriftSummary } from '../lib/store.js';
|
|
5
|
+
import { isLLMAvailable, extractReasoning } from '../lib/llm-extractor.js';
|
|
6
|
+
import { extractAnchors, findAnchorAtLine, computeCodeHash, estimateLineNumber } from '../lib/anchor-extractor.js';
|
|
7
|
+
import { debugCapture } from '../lib/debug.js';
|
|
8
|
+
import { truncate, capitalize } from '../lib/utils.js';
|
|
9
|
+
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
export async function capture(options) {
|
|
11
|
+
// Get project path from Claude Code env var, fallback to cwd
|
|
12
|
+
// CLAUDE_PROJECT_DIR is set by Claude Code when running hooks
|
|
13
|
+
const projectPath = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
14
|
+
// Find the latest session file
|
|
15
|
+
const sessionFile = findLatestSessionFile(projectPath);
|
|
16
|
+
if (!sessionFile) {
|
|
17
|
+
// No session file found - this is normal for new projects
|
|
18
|
+
// Silent exit - don't spam the user
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
// Parse the session
|
|
23
|
+
const session = parseSession(sessionFile);
|
|
24
|
+
// Skip if no user messages (empty session)
|
|
25
|
+
if (session.userMessages.length === 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Get the original query (first user message)
|
|
29
|
+
const originalQuery = session.userMessages[0];
|
|
30
|
+
let goal;
|
|
31
|
+
let reasoningTrace;
|
|
32
|
+
let filesTouched;
|
|
33
|
+
let status;
|
|
34
|
+
let tags;
|
|
35
|
+
// Use LLM extraction if available, otherwise fall back to basic extraction
|
|
36
|
+
if (isLLMAvailable()) {
|
|
37
|
+
try {
|
|
38
|
+
debugCapture('Using LLM extraction...');
|
|
39
|
+
const extracted = await extractReasoning(session);
|
|
40
|
+
goal = extracted.goal;
|
|
41
|
+
reasoningTrace = extracted.reasoning_trace;
|
|
42
|
+
filesTouched = extracted.files_touched;
|
|
43
|
+
status = extracted.status;
|
|
44
|
+
tags = extracted.tags;
|
|
45
|
+
debugCapture('LLM extraction complete: status=%s', status);
|
|
46
|
+
}
|
|
47
|
+
catch (llmError) {
|
|
48
|
+
debugCapture('LLM extraction failed, using fallback: %O', llmError);
|
|
49
|
+
// Fall back to basic extraction
|
|
50
|
+
const basic = basicExtraction(session);
|
|
51
|
+
goal = basic.goal;
|
|
52
|
+
reasoningTrace = basic.reasoningTrace;
|
|
53
|
+
filesTouched = basic.filesTouched;
|
|
54
|
+
status = basic.status;
|
|
55
|
+
tags = basic.tags;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// No API key - use basic extraction
|
|
60
|
+
const basic = basicExtraction(session);
|
|
61
|
+
goal = basic.goal;
|
|
62
|
+
reasoningTrace = basic.reasoningTrace;
|
|
63
|
+
filesTouched = basic.filesTouched;
|
|
64
|
+
status = basic.status;
|
|
65
|
+
tags = basic.tags;
|
|
66
|
+
}
|
|
67
|
+
// Get session ID for drift check
|
|
68
|
+
const sessionId = getSessionIdFromPath(sessionFile);
|
|
69
|
+
// === GRADUATION LOGIC: Check if task should be flagged for review ===
|
|
70
|
+
let finalStatus = status;
|
|
71
|
+
let finalTags = [...tags];
|
|
72
|
+
let finalReasoningTrace = [...reasoningTrace];
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
const needsReview = shouldFlagForReview(sessionId);
|
|
75
|
+
const driftSummary = getDriftSummary(sessionId);
|
|
76
|
+
if (needsReview) {
|
|
77
|
+
debugCapture('Task flagged for review due to drift');
|
|
78
|
+
// Downgrade status if was complete
|
|
79
|
+
if (finalStatus === 'complete') {
|
|
80
|
+
finalStatus = 'partial';
|
|
81
|
+
debugCapture('Status downgraded from complete to partial');
|
|
82
|
+
}
|
|
83
|
+
// Add drift tags
|
|
84
|
+
finalTags.push('needs-review', 'had-drift');
|
|
85
|
+
// Add drift summary to reasoning trace
|
|
86
|
+
if (driftSummary.totalEvents > 0) {
|
|
87
|
+
finalReasoningTrace.push(`[Drift events: ${driftSummary.totalEvents} corrections given]`);
|
|
88
|
+
finalReasoningTrace.push(`[Drift ${driftSummary.resolved ? 'resolved' : 'unresolved'}: final score ${driftSummary.finalScore}]`);
|
|
89
|
+
if (driftSummary.hadHalt) {
|
|
90
|
+
finalReasoningTrace.push('[Warning: HALT-level drift occurred during session]');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (driftSummary.totalEvents > 0) {
|
|
95
|
+
// Had drift but recovered - still note it
|
|
96
|
+
finalReasoningTrace.push(`[Drift events: ${driftSummary.totalEvents} - all resolved]`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Store the task
|
|
100
|
+
const task = createTask({
|
|
101
|
+
project_path: projectPath,
|
|
102
|
+
original_query: originalQuery,
|
|
103
|
+
goal,
|
|
104
|
+
reasoning_trace: finalReasoningTrace,
|
|
105
|
+
files_touched: filesTouched,
|
|
106
|
+
status: finalStatus,
|
|
107
|
+
tags: finalTags
|
|
108
|
+
});
|
|
109
|
+
// Create file_reasoning entries for each file touched
|
|
110
|
+
await createFileReasoningEntries(task.id, session, goal);
|
|
111
|
+
// Update session state if exists
|
|
112
|
+
if (sessionId) {
|
|
113
|
+
const sessionState = getSessionState(sessionId);
|
|
114
|
+
if (sessionState) {
|
|
115
|
+
updateSessionState(sessionId, {
|
|
116
|
+
status: finalStatus === 'complete' ? 'completed' : 'abandoned',
|
|
117
|
+
files_explored: [...new Set([...sessionState.files_explored, ...filesTouched])],
|
|
118
|
+
original_goal: goal,
|
|
119
|
+
});
|
|
120
|
+
debugCapture('Updated session state: %s...', sessionId.substring(0, 8));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Log for debugging
|
|
124
|
+
debugCapture('Captured task: %s...', task.id.substring(0, 8));
|
|
125
|
+
debugCapture('Query: %s...', originalQuery.substring(0, 50));
|
|
126
|
+
debugCapture('Files: %d', filesTouched.length);
|
|
127
|
+
debugCapture('Status: %s (original: %s)', finalStatus, status);
|
|
128
|
+
debugCapture('LLM: %s', isLLMAvailable() ? 'yes' : 'no');
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Silent fail - don't interrupt user's workflow
|
|
132
|
+
debugCapture('Capture error: %O', error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Basic extraction without LLM
|
|
137
|
+
*/
|
|
138
|
+
function basicExtraction(session) {
|
|
139
|
+
const filesTouched = [...new Set([...session.filesRead, ...session.filesWritten])];
|
|
140
|
+
const status = session.filesWritten.length > 0 ? 'complete' : 'partial';
|
|
141
|
+
return {
|
|
142
|
+
goal: session.userMessages[0] || 'Unknown goal',
|
|
143
|
+
reasoningTrace: generateBasicReasoningTrace(session),
|
|
144
|
+
filesTouched,
|
|
145
|
+
status,
|
|
146
|
+
tags: generateTags(filesTouched)
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Generate tags from file paths
|
|
151
|
+
*/
|
|
152
|
+
function generateTags(files) {
|
|
153
|
+
const tags = new Set();
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const parts = file.split('/');
|
|
156
|
+
const filename = parts[parts.length - 1];
|
|
157
|
+
// Add directory names as tags
|
|
158
|
+
for (const part of parts) {
|
|
159
|
+
if (part && !part.includes('.') && part !== 'src' && part !== 'lib') {
|
|
160
|
+
tags.add(part.toLowerCase());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Add file extension as tag
|
|
164
|
+
const ext = filename.split('.').pop();
|
|
165
|
+
if (ext && ext !== filename) {
|
|
166
|
+
tags.add(ext);
|
|
167
|
+
}
|
|
168
|
+
// Common patterns
|
|
169
|
+
if (filename.includes('auth'))
|
|
170
|
+
tags.add('auth');
|
|
171
|
+
if (filename.includes('api'))
|
|
172
|
+
tags.add('api');
|
|
173
|
+
if (filename.includes('test'))
|
|
174
|
+
tags.add('test');
|
|
175
|
+
if (filename.includes('config'))
|
|
176
|
+
tags.add('config');
|
|
177
|
+
if (filename.includes('route'))
|
|
178
|
+
tags.add('routes');
|
|
179
|
+
if (filename.includes('model'))
|
|
180
|
+
tags.add('models');
|
|
181
|
+
if (filename.includes('util'))
|
|
182
|
+
tags.add('utils');
|
|
183
|
+
}
|
|
184
|
+
return [...tags].slice(0, 10); // Limit to 10 tags
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Generate basic reasoning trace from session data
|
|
188
|
+
*/
|
|
189
|
+
function generateBasicReasoningTrace(session) {
|
|
190
|
+
const trace = [];
|
|
191
|
+
// Count tool usage
|
|
192
|
+
const toolCounts = session.toolCalls.reduce((acc, t) => {
|
|
193
|
+
acc[t.name] = (acc[t.name] || 0) + 1;
|
|
194
|
+
return acc;
|
|
195
|
+
}, {});
|
|
196
|
+
// Add tool usage summary
|
|
197
|
+
if (toolCounts['Read']) {
|
|
198
|
+
trace.push(`Read ${toolCounts['Read']} files`);
|
|
199
|
+
}
|
|
200
|
+
if (toolCounts['Write']) {
|
|
201
|
+
trace.push(`Wrote ${toolCounts['Write']} files`);
|
|
202
|
+
}
|
|
203
|
+
if (toolCounts['Edit']) {
|
|
204
|
+
trace.push(`Edited ${toolCounts['Edit']} files`);
|
|
205
|
+
}
|
|
206
|
+
if (toolCounts['Grep'] || toolCounts['Glob']) {
|
|
207
|
+
trace.push(`Searched codebase`);
|
|
208
|
+
}
|
|
209
|
+
if (toolCounts['Bash']) {
|
|
210
|
+
trace.push(`Ran ${toolCounts['Bash']} commands`);
|
|
211
|
+
}
|
|
212
|
+
// Add file summaries
|
|
213
|
+
if (session.filesRead.length > 0) {
|
|
214
|
+
trace.push(`Files examined: ${session.filesRead.slice(0, 5).map(f => f.split('/').pop()).join(', ')}`);
|
|
215
|
+
}
|
|
216
|
+
if (session.filesWritten.length > 0) {
|
|
217
|
+
trace.push(`Files modified: ${session.filesWritten.map(f => f.split('/').pop()).join(', ')}`);
|
|
218
|
+
}
|
|
219
|
+
return trace;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Create file_reasoning entries for each file touched in the session
|
|
223
|
+
*/
|
|
224
|
+
async function createFileReasoningEntries(taskId, session, goal) {
|
|
225
|
+
try {
|
|
226
|
+
// Process files that were written/edited
|
|
227
|
+
for (const filePath of session.filesWritten) {
|
|
228
|
+
await createFileReasoningForFile(taskId, filePath, session, goal, true);
|
|
229
|
+
}
|
|
230
|
+
// Also process files that were only read (with less detail)
|
|
231
|
+
for (const filePath of session.filesRead) {
|
|
232
|
+
// Skip if already processed as written
|
|
233
|
+
if (session.filesWritten.includes(filePath))
|
|
234
|
+
continue;
|
|
235
|
+
await createFileReasoningForFile(taskId, filePath, session, goal, false);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
debugCapture('Error creating file reasoning entries: %O', error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create a file_reasoning entry for a specific file
|
|
244
|
+
*/
|
|
245
|
+
async function createFileReasoningForFile(taskId, filePath, session, goal, wasModified) {
|
|
246
|
+
try {
|
|
247
|
+
// SECURITY: Validate path is within project boundary to prevent path traversal
|
|
248
|
+
const projectPath = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
249
|
+
if (!isPathWithinProject(projectPath, filePath)) {
|
|
250
|
+
debugCapture('Skipping file outside project boundary: %s', filePath);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Check if file exists
|
|
254
|
+
if (!existsSync(filePath)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Read file content
|
|
258
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
259
|
+
// Extract anchors from the file
|
|
260
|
+
const anchors = extractAnchors(filePath, content);
|
|
261
|
+
// Find the Edit tool call for this file to determine what was changed
|
|
262
|
+
const editCalls = session.toolCalls.filter(t => t.name === 'Edit' && t.input?.file_path === filePath);
|
|
263
|
+
if (editCalls.length > 0 && wasModified) {
|
|
264
|
+
// For each edit, try to find the anchor
|
|
265
|
+
for (const editCall of editCalls) {
|
|
266
|
+
const input = editCall.input;
|
|
267
|
+
if (input.old_string) {
|
|
268
|
+
const lineNumber = estimateLineNumber(input.old_string, content);
|
|
269
|
+
const anchor = lineNumber ? findAnchorAtLine(anchors, lineNumber) : null;
|
|
270
|
+
const lineStart = anchor?.lineStart || lineNumber || undefined;
|
|
271
|
+
const lineEnd = anchor?.lineEnd || lineNumber || undefined;
|
|
272
|
+
createFileReasoning({
|
|
273
|
+
task_id: taskId,
|
|
274
|
+
file_path: filePath,
|
|
275
|
+
anchor: anchor?.name,
|
|
276
|
+
line_start: lineStart,
|
|
277
|
+
line_end: lineEnd,
|
|
278
|
+
code_hash: lineStart && lineEnd ? computeCodeHash(content, lineStart, lineEnd) : undefined,
|
|
279
|
+
change_type: 'edit',
|
|
280
|
+
reasoning: buildReasoningString(anchor, goal, 'edited')
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (wasModified) {
|
|
286
|
+
// File was created/written without Edit
|
|
287
|
+
const writeCalls = session.toolCalls.filter(t => t.name === 'Write' && t.input?.file_path === filePath);
|
|
288
|
+
const changeType = writeCalls.length > 0 ? 'create' : 'write';
|
|
289
|
+
createFileReasoning({
|
|
290
|
+
task_id: taskId,
|
|
291
|
+
file_path: filePath,
|
|
292
|
+
anchor: anchors.length > 0 ? anchors[0].name : undefined,
|
|
293
|
+
line_start: 1,
|
|
294
|
+
line_end: content.split('\n').length,
|
|
295
|
+
code_hash: computeCodeHash(content, 1, content.split('\n').length),
|
|
296
|
+
change_type: changeType,
|
|
297
|
+
reasoning: buildReasoningString(null, goal, changeType === 'create' ? 'created' : 'wrote')
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// File was only read
|
|
302
|
+
createFileReasoning({
|
|
303
|
+
task_id: taskId,
|
|
304
|
+
file_path: filePath,
|
|
305
|
+
anchor: anchors.length > 0 ? anchors[0].name : undefined,
|
|
306
|
+
change_type: 'read',
|
|
307
|
+
reasoning: `Read during: ${truncate(goal, 80)}`
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
debugCapture('Error processing file %s: %O', filePath, error);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Build a reasoning string for a file modification
|
|
317
|
+
*/
|
|
318
|
+
function buildReasoningString(anchor, goal, action) {
|
|
319
|
+
const shortGoal = truncate(goal, 80);
|
|
320
|
+
if (anchor) {
|
|
321
|
+
return `${capitalize(action)} ${anchor.type} "${anchor.name}": ${shortGoal}`;
|
|
322
|
+
}
|
|
323
|
+
return `${capitalize(action)} file: ${shortGoal}`;
|
|
324
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// grov drift-test - Debug command for testing drift detection
|
|
2
|
+
// Usage: grov drift-test "your prompt here" [--session <id>] [--goal "original goal"]
|
|
3
|
+
//
|
|
4
|
+
// NOTE: This command creates mock ACTIONS from the prompt for testing.
|
|
5
|
+
// In real usage, actions are parsed from Claude's JSONL session file.
|
|
6
|
+
import 'dotenv/config';
|
|
7
|
+
import { getSessionState, createSessionState } from '../lib/store.js';
|
|
8
|
+
import { extractIntent, isAnthropicAvailable } from '../lib/llm-extractor.js';
|
|
9
|
+
import { buildDriftCheckInput, checkDrift, checkDriftBasic, DRIFT_CONFIG } from '../lib/drift-checker.js';
|
|
10
|
+
import { determineCorrectionLevel, buildCorrection } from '../lib/correction-builder.js';
|
|
11
|
+
export async function driftTest(prompt, options) {
|
|
12
|
+
console.log('=== GROV DRIFT TEST ===\n');
|
|
13
|
+
// Check API availability
|
|
14
|
+
const llmAvailable = isAnthropicAvailable();
|
|
15
|
+
console.log(`Anthropic API: ${llmAvailable ? 'AVAILABLE' : 'NOT AVAILABLE (using fallback)'}`);
|
|
16
|
+
console.log('');
|
|
17
|
+
// Get or create session state
|
|
18
|
+
let sessionState = options.session ? getSessionState(options.session) : null;
|
|
19
|
+
// If no session, create a test one with provided or extracted goal
|
|
20
|
+
if (!sessionState) {
|
|
21
|
+
console.log('No session provided, creating test session...');
|
|
22
|
+
const goalText = options.goal || prompt;
|
|
23
|
+
const intent = await extractIntent(goalText);
|
|
24
|
+
console.log('\n--- Extracted Intent ---');
|
|
25
|
+
console.log(`Goal: ${intent.goal}`);
|
|
26
|
+
console.log(`Scope: ${intent.expected_scope.join(', ') || 'none'}`);
|
|
27
|
+
console.log(`Constraints: ${intent.constraints.join(', ') || 'none'}`);
|
|
28
|
+
console.log(`Keywords: ${intent.keywords.join(', ')}`);
|
|
29
|
+
console.log('');
|
|
30
|
+
// Create temporary session state in memory (not persisted unless session ID provided)
|
|
31
|
+
sessionState = {
|
|
32
|
+
session_id: options.session || 'test-session',
|
|
33
|
+
project_path: process.cwd(),
|
|
34
|
+
original_goal: intent.goal,
|
|
35
|
+
actions_taken: [],
|
|
36
|
+
files_explored: [],
|
|
37
|
+
current_intent: undefined,
|
|
38
|
+
drift_warnings: [],
|
|
39
|
+
start_time: new Date().toISOString(),
|
|
40
|
+
last_update: new Date().toISOString(),
|
|
41
|
+
status: 'active',
|
|
42
|
+
expected_scope: intent.expected_scope,
|
|
43
|
+
constraints: intent.constraints,
|
|
44
|
+
success_criteria: intent.success_criteria,
|
|
45
|
+
keywords: intent.keywords,
|
|
46
|
+
last_drift_score: undefined,
|
|
47
|
+
escalation_count: 0,
|
|
48
|
+
pending_recovery_plan: undefined,
|
|
49
|
+
drift_history: [],
|
|
50
|
+
last_checked_at: 0 // New field for action tracking
|
|
51
|
+
};
|
|
52
|
+
// Persist if session ID was provided
|
|
53
|
+
if (options.session) {
|
|
54
|
+
try {
|
|
55
|
+
createSessionState({
|
|
56
|
+
session_id: options.session,
|
|
57
|
+
project_path: process.cwd(),
|
|
58
|
+
original_goal: intent.goal,
|
|
59
|
+
expected_scope: intent.expected_scope,
|
|
60
|
+
constraints: intent.constraints,
|
|
61
|
+
success_criteria: intent.success_criteria,
|
|
62
|
+
keywords: intent.keywords
|
|
63
|
+
});
|
|
64
|
+
console.log(`Session state persisted: ${options.session}`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Might already exist, ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.log(`Using existing session: ${options.session}`);
|
|
73
|
+
console.log(`Original goal: ${sessionState.original_goal}`);
|
|
74
|
+
console.log(`Escalation count: ${sessionState.escalation_count}`);
|
|
75
|
+
console.log(`Drift history: ${sessionState.drift_history.length} events`);
|
|
76
|
+
console.log('');
|
|
77
|
+
}
|
|
78
|
+
// Ensure sessionState is not null at this point
|
|
79
|
+
if (!sessionState) {
|
|
80
|
+
console.error('Failed to create session state');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
// Create mock actions from prompt for testing
|
|
84
|
+
// In real usage, actions are parsed from Claude's JSONL session file
|
|
85
|
+
const mockFiles = extractFilesFromPrompt(prompt);
|
|
86
|
+
const mockActions = mockFiles.length > 0
|
|
87
|
+
? mockFiles.map((file, i) => ({
|
|
88
|
+
type: 'edit',
|
|
89
|
+
files: [file],
|
|
90
|
+
timestamp: Date.now() + i * 1000
|
|
91
|
+
}))
|
|
92
|
+
: [{ type: 'edit', files: ['mock-file.ts'], timestamp: Date.now() }];
|
|
93
|
+
console.log('--- Mock Actions (from prompt) ---');
|
|
94
|
+
console.log(`Files detected: ${mockFiles.join(', ') || 'none (using mock-file.ts)'}`);
|
|
95
|
+
console.log('');
|
|
96
|
+
// Build drift check input using ACTIONS (not prompt!)
|
|
97
|
+
const driftInput = buildDriftCheckInput(mockActions, sessionState.session_id, sessionState);
|
|
98
|
+
console.log('--- Drift Check Input ---');
|
|
99
|
+
console.log(`Actions: ${mockActions.map(a => `${a.type}:${a.files.join(',')}`).join(' | ')}`);
|
|
100
|
+
console.log('');
|
|
101
|
+
// Run drift check
|
|
102
|
+
console.log('--- Running Drift Check ---');
|
|
103
|
+
let result;
|
|
104
|
+
if (llmAvailable) {
|
|
105
|
+
console.log('Using LLM-based detection...');
|
|
106
|
+
result = await checkDrift(driftInput);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log('Using basic (fallback) detection...');
|
|
110
|
+
result = checkDriftBasic(driftInput);
|
|
111
|
+
}
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log('--- Drift Check Result ---');
|
|
114
|
+
console.log(`Score: ${result.score}/10`);
|
|
115
|
+
console.log(`Type: ${result.type}`);
|
|
116
|
+
console.log(`Diagnostic: ${result.diagnostic}`);
|
|
117
|
+
if (result.boundaries.length > 0) {
|
|
118
|
+
console.log(`Boundaries: ${result.boundaries.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
if (result.recoveryPlan?.steps) {
|
|
121
|
+
console.log('Recovery steps:');
|
|
122
|
+
for (const step of result.recoveryPlan.steps) {
|
|
123
|
+
const file = step.file ? `[${step.file}] ` : '';
|
|
124
|
+
console.log(` - ${file}${step.action}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
console.log('');
|
|
128
|
+
// Determine correction level
|
|
129
|
+
const level = determineCorrectionLevel(result.score, sessionState.escalation_count);
|
|
130
|
+
console.log('--- Correction Level ---');
|
|
131
|
+
console.log(`Level: ${level || 'NONE (no correction needed)'}`);
|
|
132
|
+
console.log('');
|
|
133
|
+
// Show thresholds
|
|
134
|
+
console.log('--- Thresholds (with escalation=%d) ---', sessionState.escalation_count);
|
|
135
|
+
console.log(`>= ${DRIFT_CONFIG.SCORE_NO_INJECTION - sessionState.escalation_count}: No correction`);
|
|
136
|
+
console.log(`>= ${DRIFT_CONFIG.SCORE_NUDGE - sessionState.escalation_count}: Nudge`);
|
|
137
|
+
console.log(`>= ${DRIFT_CONFIG.SCORE_CORRECT - sessionState.escalation_count}: Correct`);
|
|
138
|
+
console.log(`>= ${DRIFT_CONFIG.SCORE_INTERVENE - sessionState.escalation_count}: Intervene`);
|
|
139
|
+
console.log(`< ${DRIFT_CONFIG.SCORE_INTERVENE - sessionState.escalation_count}: Halt`);
|
|
140
|
+
console.log('');
|
|
141
|
+
// Build and show correction if applicable
|
|
142
|
+
if (level) {
|
|
143
|
+
console.log('--- Correction Output ---');
|
|
144
|
+
const correction = buildCorrection(result, sessionState, level);
|
|
145
|
+
console.log(correction);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.log('No correction needed for this prompt.');
|
|
149
|
+
}
|
|
150
|
+
console.log('\n=== END DRIFT TEST ===');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Extract file paths from a prompt for mock action creation
|
|
154
|
+
*/
|
|
155
|
+
function extractFilesFromPrompt(prompt) {
|
|
156
|
+
const patterns = [
|
|
157
|
+
// Absolute paths: /Users/dev/file.ts
|
|
158
|
+
/(?:^|\s)(\/[\w\-\.\/]+\.\w+)/g,
|
|
159
|
+
// Relative paths with ./: ./src/file.ts
|
|
160
|
+
/(?:^|\s)(\.\/[\w\-\.\/]+\.\w+)/g,
|
|
161
|
+
// Relative paths: src/file.ts or path/to/file.ts
|
|
162
|
+
/(?:^|\s)([\w\-]+\/[\w\-\.\/]+\.\w+)/g,
|
|
163
|
+
// Simple filenames with extension: file.ts
|
|
164
|
+
/(?:^|\s|['"`])([\w\-]+\.\w{1,5})(?:\s|$|,|:|['"`])/g,
|
|
165
|
+
];
|
|
166
|
+
const files = new Set();
|
|
167
|
+
for (const pattern of patterns) {
|
|
168
|
+
const matches = prompt.matchAll(pattern);
|
|
169
|
+
for (const match of matches) {
|
|
170
|
+
const file = match[1].trim();
|
|
171
|
+
if (file && !file.match(/^(http|https|ftp|mailto|tel)/) && !file.match(/^\d+\.\d+/)) {
|
|
172
|
+
files.add(file);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...files];
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): Promise<void>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// grov init - Register hooks in Claude Code settings
|
|
2
|
+
import { registerGrovHooks, getSettingsPath } from '../lib/hooks.js';
|
|
3
|
+
export async function init() {
|
|
4
|
+
console.log('Registering grov hooks in Claude Code...\n');
|
|
5
|
+
try {
|
|
6
|
+
const { added, alreadyExists } = registerGrovHooks();
|
|
7
|
+
if (added.length > 0) {
|
|
8
|
+
console.log('Added hooks:');
|
|
9
|
+
added.forEach(hook => console.log(` + ${hook}`));
|
|
10
|
+
}
|
|
11
|
+
if (alreadyExists.length > 0) {
|
|
12
|
+
console.log('Already registered:');
|
|
13
|
+
alreadyExists.forEach(hook => console.log(` = ${hook}`));
|
|
14
|
+
}
|
|
15
|
+
console.log(`\nSettings file: ${getSettingsPath()}`);
|
|
16
|
+
console.log('\nGrov is now active! Your Claude Code sessions will automatically:');
|
|
17
|
+
console.log(' - Capture reasoning after each task (Stop hook)');
|
|
18
|
+
console.log(' - Inject relevant context at session start (SessionStart hook)');
|
|
19
|
+
console.log(' - Inject targeted context before each prompt (UserPromptSubmit hook)');
|
|
20
|
+
console.log('\nJust use Claude Code normally. Grov works in the background.');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
// SECURITY: Only show error message, not full stack trace with paths
|
|
24
|
+
console.error('Failed to register hooks:', error instanceof Error ? error.message : 'Unknown error');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// grov inject - Called by SessionStart hook, outputs context JSON
|
|
2
|
+
import { getTasksForProject, createSessionState, getSessionState } from '../lib/store.js';
|
|
3
|
+
import { getCurrentSessionId } from '../lib/jsonl-parser.js';
|
|
4
|
+
import { debugInject } from '../lib/debug.js';
|
|
5
|
+
import { truncate } from '../lib/utils.js';
|
|
6
|
+
export async function inject(options) {
|
|
7
|
+
debugInject('inject called - CLAUDE_PROJECT_DIR=%s, cwd=%s', process.env.CLAUDE_PROJECT_DIR || 'NOT_SET', process.cwd());
|
|
8
|
+
try {
|
|
9
|
+
// Get project path from Claude Code env var, fallback to cwd
|
|
10
|
+
// CLAUDE_PROJECT_DIR is set by Claude Code when running hooks
|
|
11
|
+
const projectPath = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
12
|
+
// Initialize session state for this session
|
|
13
|
+
const sessionId = getCurrentSessionId(projectPath);
|
|
14
|
+
if (sessionId) {
|
|
15
|
+
const existing = getSessionState(sessionId);
|
|
16
|
+
if (!existing) {
|
|
17
|
+
createSessionState({
|
|
18
|
+
session_id: sessionId,
|
|
19
|
+
project_path: projectPath,
|
|
20
|
+
user_id: process.env.USER || undefined,
|
|
21
|
+
});
|
|
22
|
+
debugInject('Created session state: %s...', sessionId.substring(0, 8));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Get completed tasks for this project
|
|
26
|
+
const tasks = getTasksForProject(projectPath, {
|
|
27
|
+
status: 'complete',
|
|
28
|
+
limit: 5 // Only inject most recent 5
|
|
29
|
+
});
|
|
30
|
+
// Build context string
|
|
31
|
+
const context = buildContextString(tasks);
|
|
32
|
+
// Only output if we have context to inject
|
|
33
|
+
// Claude Code expects JSON with hookEventName for SessionStart hooks
|
|
34
|
+
if (context) {
|
|
35
|
+
const output = {
|
|
36
|
+
hookSpecificOutput: {
|
|
37
|
+
hookEventName: "SessionStart",
|
|
38
|
+
additionalContext: context
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
console.log(JSON.stringify(output));
|
|
42
|
+
}
|
|
43
|
+
// If no context, output nothing - this is cleaner for Claude Code
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// On error, output nothing - don't break the session
|
|
47
|
+
// Silent fail is better than outputting potentially invalid JSON
|
|
48
|
+
debugInject('Inject error: %O', error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the context string to inject
|
|
53
|
+
*/
|
|
54
|
+
function buildContextString(tasks) {
|
|
55
|
+
if (tasks.length === 0) {
|
|
56
|
+
return ''; // No context to inject
|
|
57
|
+
}
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push('VERIFIED CONTEXT FROM PREVIOUS SESSIONS:');
|
|
60
|
+
lines.push('(This context was captured from your previous work on this codebase)');
|
|
61
|
+
lines.push('');
|
|
62
|
+
for (const task of tasks) {
|
|
63
|
+
lines.push(`[Task: ${truncate(task.original_query, 80)}]`);
|
|
64
|
+
// Files touched
|
|
65
|
+
if (task.files_touched.length > 0) {
|
|
66
|
+
const fileList = task.files_touched
|
|
67
|
+
.slice(0, 5)
|
|
68
|
+
.map(f => f.split('/').pop())
|
|
69
|
+
.join(', ');
|
|
70
|
+
lines.push(`- Files: ${fileList}${task.files_touched.length > 5 ? ` (+${task.files_touched.length - 5} more)` : ''}`);
|
|
71
|
+
}
|
|
72
|
+
// Reasoning trace
|
|
73
|
+
if (task.reasoning_trace.length > 0) {
|
|
74
|
+
for (const trace of task.reasoning_trace.slice(0, 3)) {
|
|
75
|
+
lines.push(`- ${trace}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Tags
|
|
79
|
+
if (task.tags.length > 0) {
|
|
80
|
+
lines.push(`- Tags: ${task.tags.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
lines.push('');
|
|
83
|
+
}
|
|
84
|
+
// Add instruction for Claude
|
|
85
|
+
lines.push('YOU MAY SKIP EXPLORE AGENTS for files mentioned above.');
|
|
86
|
+
lines.push('Read them directly if relevant to the current task.');
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|