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.
Files changed (39) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +211 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +106 -0
  5. package/dist/commands/capture.d.ts +6 -0
  6. package/dist/commands/capture.js +324 -0
  7. package/dist/commands/drift-test.d.ts +7 -0
  8. package/dist/commands/drift-test.js +177 -0
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +27 -0
  11. package/dist/commands/inject.d.ts +5 -0
  12. package/dist/commands/inject.js +88 -0
  13. package/dist/commands/prompt-inject.d.ts +4 -0
  14. package/dist/commands/prompt-inject.js +451 -0
  15. package/dist/commands/status.d.ts +5 -0
  16. package/dist/commands/status.js +51 -0
  17. package/dist/commands/unregister.d.ts +1 -0
  18. package/dist/commands/unregister.js +22 -0
  19. package/dist/lib/anchor-extractor.d.ts +30 -0
  20. package/dist/lib/anchor-extractor.js +296 -0
  21. package/dist/lib/correction-builder.d.ts +10 -0
  22. package/dist/lib/correction-builder.js +226 -0
  23. package/dist/lib/debug.d.ts +24 -0
  24. package/dist/lib/debug.js +34 -0
  25. package/dist/lib/drift-checker.d.ts +66 -0
  26. package/dist/lib/drift-checker.js +341 -0
  27. package/dist/lib/hooks.d.ts +27 -0
  28. package/dist/lib/hooks.js +258 -0
  29. package/dist/lib/jsonl-parser.d.ts +87 -0
  30. package/dist/lib/jsonl-parser.js +281 -0
  31. package/dist/lib/llm-extractor.d.ts +50 -0
  32. package/dist/lib/llm-extractor.js +408 -0
  33. package/dist/lib/session-parser.d.ts +44 -0
  34. package/dist/lib/session-parser.js +256 -0
  35. package/dist/lib/store.d.ts +248 -0
  36. package/dist/lib/store.js +793 -0
  37. package/dist/lib/utils.d.ts +31 -0
  38. package/dist/lib/utils.js +76 -0
  39. 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,7 @@
1
+ import 'dotenv/config';
2
+ export interface DriftTestOptions {
3
+ session?: string;
4
+ goal?: string;
5
+ verbose?: boolean;
6
+ }
7
+ export declare function driftTest(prompt: string, options: DriftTestOptions): Promise<void>;
@@ -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,5 @@
1
+ interface InjectOptions {
2
+ task?: string;
3
+ }
4
+ export declare function inject(options: InjectOptions): Promise<void>;
5
+ export {};
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import 'dotenv/config';
2
+ export interface PromptInjectOptions {
3
+ }
4
+ export declare function promptInject(_options: PromptInjectOptions): Promise<void>;