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,341 @@
1
+ // Drift detection logic for anti-drift system
2
+ // Uses Claude Haiku 4.5 for LLM-based drift scoring
3
+ //
4
+ // CRITICAL: We check Claude's ACTIONS, NOT user prompts.
5
+ // User can explore freely. We monitor what CLAUDE DOES.
6
+ import Anthropic from '@anthropic-ai/sdk';
7
+ import { isAnthropicAvailable, getDriftModel } from './llm-extractor.js';
8
+ import { getRelevantSteps, getRecentSteps } from './store.js';
9
+ import { extractFilesFromActions, extractFoldersFromActions } from './session-parser.js';
10
+ import { debugInject } from './debug.js';
11
+ // ============================================
12
+ // CONFIGURATION
13
+ // ============================================
14
+ export const DRIFT_CONFIG = {
15
+ SCORE_NO_INJECTION: 8, // >= 8: no correction
16
+ SCORE_NUDGE: 7, // 7: nudge
17
+ SCORE_CORRECT: 5, // 5-6: correct
18
+ SCORE_INTERVENE: 3, // 3-4: intervene
19
+ SCORE_HALT: 1, // 1-2: halt
20
+ MAX_WARNINGS_BEFORE_FLAG: 3,
21
+ AVG_SCORE_THRESHOLD: 6,
22
+ MAX_ESCALATION: 3,
23
+ };
24
+ // ============================================
25
+ // MAIN FUNCTIONS
26
+ // ============================================
27
+ /**
28
+ * Build input for drift check from Claude's ACTIONS and session state.
29
+ *
30
+ * CRITICAL: We check ACTIONS, not user prompts.
31
+ */
32
+ export function buildDriftCheckInput(claudeActions, sessionId, sessionState) {
33
+ // Extract files/folders from current actions for retrieval
34
+ const currentFiles = extractFilesFromActions(claudeActions);
35
+ const currentFolders = extractFoldersFromActions(claudeActions);
36
+ return {
37
+ originalGoal: sessionState.original_goal || '',
38
+ expectedScope: sessionState.expected_scope,
39
+ constraints: sessionState.constraints,
40
+ keywords: sessionState.keywords,
41
+ driftHistory: sessionState.drift_history.map(h => ({
42
+ score: h.score,
43
+ level: h.level
44
+ })),
45
+ escalationCount: sessionState.escalation_count,
46
+ // Claude's ACTIONS
47
+ claudeActions,
48
+ // 4-query retrieval for context
49
+ retrievedSteps: getRelevantSteps(sessionId, currentFiles, currentFolders, sessionState.keywords, 10),
50
+ lastNSteps: getRecentSteps(sessionId, 5)
51
+ };
52
+ }
53
+ /**
54
+ * Check drift using LLM or fallback
55
+ */
56
+ export async function checkDrift(input) {
57
+ // Try LLM if available
58
+ if (isAnthropicAvailable()) {
59
+ try {
60
+ return await checkDriftWithLLM(input);
61
+ }
62
+ catch (error) {
63
+ debugInject('checkDrift LLM failed, using fallback: %O', error);
64
+ return checkDriftBasic(input);
65
+ }
66
+ }
67
+ // Fallback to basic detection
68
+ return checkDriftBasic(input);
69
+ }
70
+ /**
71
+ * LLM-based drift detection using Claude Haiku 4.5.
72
+ *
73
+ * CRITICAL: Analyzes Claude's ACTIONS, not user prompts.
74
+ */
75
+ async function checkDriftWithLLM(input) {
76
+ const anthropic = new Anthropic();
77
+ const model = getDriftModel();
78
+ // Format Claude's actions for the prompt
79
+ const actionsText = input.claudeActions.length > 0
80
+ ? input.claudeActions.map(a => {
81
+ if (a.type === 'bash')
82
+ return `- ${a.type}: ${a.command?.substring(0, 100) || 'no command'}`;
83
+ return `- ${a.type}: ${a.files.join(', ') || 'no files'}`;
84
+ }).join('\n')
85
+ : 'No actions yet';
86
+ // Format recent steps for context
87
+ const recentStepsText = input.lastNSteps.length > 0
88
+ ? input.lastNSteps.map(s => `- ${s.action_type}: ${s.files.slice(0, 2).join(', ')} (score: ${s.drift_score})`).join('\n')
89
+ : 'No previous steps';
90
+ const driftContext = input.driftHistory.length > 0
91
+ ? `Previous drift events: ${input.driftHistory.map(h => `score=${h.score}`).join(', ')}`
92
+ : 'No previous drift events';
93
+ const response = await anthropic.messages.create({
94
+ model,
95
+ max_tokens: 1024,
96
+ messages: [
97
+ {
98
+ role: 'user',
99
+ content: `You are a drift detection system. Analyze if Claude's ACTIONS align with the original goal.
100
+
101
+ IMPORTANT: We monitor Claude's ACTIONS, not user prompts. Users can ask anything - we check what Claude DOES.
102
+
103
+ ORIGINAL GOAL:
104
+ ${input.originalGoal}
105
+
106
+ EXPECTED SCOPE (files/components Claude should touch):
107
+ ${input.expectedScope.length > 0 ? input.expectedScope.join(', ') : 'Not specified'}
108
+
109
+ CONSTRAINTS:
110
+ ${input.constraints.length > 0 ? input.constraints.join(', ') : 'None specified'}
111
+
112
+ KEY TERMS:
113
+ ${input.keywords.join(', ')}
114
+
115
+ ${driftContext}
116
+ Current escalation level: ${input.escalationCount}
117
+
118
+ CLAUDE'S RECENT ACTIONS:
119
+ ${actionsText}
120
+
121
+ PREVIOUS STEPS IN SESSION:
122
+ ${recentStepsText}
123
+
124
+ CHECK FOR:
125
+ 1. Files OUTSIDE expected scope (editing unrelated files)
126
+ 2. Repetition patterns (same file edited 3+ times without progress)
127
+ 3. Tangential work (styling when goal is auth)
128
+ 4. New features not requested
129
+ 5. "While I'm here" patterns (scope creep)
130
+
131
+ LEGITIMATE (NOT drift):
132
+ - Editing utility files imported by main files
133
+ - Fixing bugs discovered while working
134
+ - Updating tests for modified code
135
+ - Reading ANY file (exploration is OK)
136
+
137
+ Rate 1-10:
138
+ - 10: Actions directly advance the goal
139
+ - 8-9: Minor deviation but related (e.g., helper file)
140
+ - 5-7: Moderate drift, tangentially related
141
+ - 3-4: Significant drift, unrelated files
142
+ - 1-2: Critical drift, completely off-track
143
+
144
+ Return ONLY valid JSON:
145
+ {
146
+ "score": <1-10>,
147
+ "type": "aligned|minor|moderate|severe|critical",
148
+ "diagnostic": "Brief explanation of drift based on ACTIONS (1 sentence)",
149
+ "recovery_steps": [{"file": "optional/path", "action": "what to do"}],
150
+ "boundaries": ["Things that should NOT be done"],
151
+ "verification": "How to confirm we're back on track"
152
+ }
153
+
154
+ Return ONLY valid JSON.`
155
+ }
156
+ ]
157
+ });
158
+ // Extract text content
159
+ const content = response.content[0];
160
+ if (content.type !== 'text') {
161
+ throw new Error('Unexpected response type');
162
+ }
163
+ // Strip markdown code blocks if present
164
+ let jsonText = content.text.trim();
165
+ if (jsonText.startsWith('```')) {
166
+ jsonText = jsonText.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
167
+ }
168
+ debugInject('LLM raw response: %s', jsonText.substring(0, 200));
169
+ const parsed = JSON.parse(jsonText);
170
+ // Handle score as string or number
171
+ const rawScore = typeof parsed.score === 'string' ? parseInt(parsed.score, 10) : parsed.score;
172
+ const score = Math.min(10, Math.max(1, rawScore || 5));
173
+ debugInject('LLM parsed score: raw=%s, final=%d', parsed.score, score);
174
+ return {
175
+ score,
176
+ type: mapScoreToType(score),
177
+ diagnostic: parsed.diagnostic || 'Unable to determine drift status',
178
+ recoveryPlan: parsed.recovery_steps ? { steps: parsed.recovery_steps } : undefined,
179
+ boundaries: parsed.boundaries || [],
180
+ verification: parsed.verification || 'Complete the original task'
181
+ };
182
+ }
183
+ /**
184
+ * Basic drift detection without LLM.
185
+ *
186
+ * CRITICAL: Checks Claude's ACTIONS, not user prompts.
187
+ * - Read actions are ALWAYS OK (exploration is not drift)
188
+ * - Edit/Write actions outside scope = drift
189
+ * - Repetition patterns = drift
190
+ */
191
+ export function checkDriftBasic(input) {
192
+ const issues = [];
193
+ // Filter to modifying actions only (read is always OK)
194
+ const modifyingActions = input.claudeActions.filter(a => a.type !== 'read' && a.type !== 'grep' && a.type !== 'glob');
195
+ // No modifying actions = no drift possible
196
+ if (modifyingActions.length === 0) {
197
+ return {
198
+ score: 10,
199
+ type: 'aligned',
200
+ diagnostic: 'No modifying actions - exploration only',
201
+ boundaries: [],
202
+ verification: `Continue with: ${input.originalGoal.substring(0, 50)}`
203
+ };
204
+ }
205
+ // Count files in-scope vs out-of-scope
206
+ let inScopeCount = 0;
207
+ let outOfScopeCount = 0;
208
+ const allFiles = [];
209
+ for (const action of modifyingActions) {
210
+ for (const file of action.files) {
211
+ if (!file)
212
+ continue;
213
+ allFiles.push(file);
214
+ // If no scope defined, assume all files are OK
215
+ if (input.expectedScope.length === 0) {
216
+ inScopeCount++;
217
+ continue;
218
+ }
219
+ // Check if file is in scope
220
+ const inScope = input.expectedScope.some(scope => {
221
+ // file contains scope pattern (e.g., "src/auth/token.ts" contains "src/auth/")
222
+ if (file.includes(scope))
223
+ return true;
224
+ // scope contains the file name (e.g., "src/lib/token.ts" contains "token.ts")
225
+ const fileName = file.split('/').pop() || '';
226
+ if (scope.includes(fileName) && fileName.length > 0)
227
+ return true;
228
+ return false;
229
+ });
230
+ if (inScope) {
231
+ inScopeCount++;
232
+ }
233
+ else {
234
+ outOfScopeCount++;
235
+ issues.push(`File outside scope: ${file.split('/').pop()}`);
236
+ }
237
+ }
238
+ }
239
+ // Calculate score based on in-scope ratio
240
+ let score;
241
+ const totalFiles = inScopeCount + outOfScopeCount;
242
+ if (totalFiles === 0) {
243
+ score = 10;
244
+ }
245
+ else if (outOfScopeCount === 0) {
246
+ // All files in scope = perfect
247
+ score = 10;
248
+ }
249
+ else if (inScopeCount === 0) {
250
+ // All files out of scope = critical drift
251
+ score = Math.max(1, 4 - outOfScopeCount); // 1-4 depending on how many
252
+ }
253
+ else {
254
+ // Mixed: some in, some out
255
+ const ratio = inScopeCount / totalFiles;
256
+ // ratio 1.0 = 10, ratio 0.5 = 6, ratio 0.0 = 2
257
+ score = Math.round(2 + ratio * 8);
258
+ // Additional penalty for each out-of-scope file
259
+ score = Math.max(1, score - outOfScopeCount);
260
+ }
261
+ // Check for repetition patterns (same file edited 3+ times)
262
+ // Use unique files to avoid double-counting
263
+ const recentFiles = input.lastNSteps.flatMap(s => s.files);
264
+ const uniqueCurrentFiles = [...new Set(allFiles)];
265
+ for (const file of uniqueCurrentFiles) {
266
+ const timesEdited = recentFiles.filter(f => f === file).length;
267
+ if (timesEdited >= 3) {
268
+ score = Math.max(1, score - 2);
269
+ issues.push(`Repetition: ${file.split('/').pop()} edited ${timesEdited}+ times`);
270
+ }
271
+ }
272
+ // Clamp score
273
+ score = Math.max(1, Math.min(10, score));
274
+ // Determine diagnostic
275
+ let diagnostic;
276
+ if (score >= 8) {
277
+ diagnostic = 'Actions align with original goal';
278
+ }
279
+ else if (score >= 5) {
280
+ diagnostic = issues.length > 0 ? issues[0] : 'Actions partially relate to goal';
281
+ }
282
+ else if (score >= 3) {
283
+ diagnostic = issues.length > 0 ? issues.join('; ') : 'Actions deviate from goal';
284
+ }
285
+ else {
286
+ diagnostic = issues.length > 0 ? issues.join('; ') : 'Actions do not relate to original goal';
287
+ }
288
+ return {
289
+ score,
290
+ type: mapScoreToType(score),
291
+ diagnostic,
292
+ recoveryPlan: score < 5 ? { steps: [{ action: `Return to: ${input.originalGoal.substring(0, 50)}` }] } : undefined,
293
+ boundaries: [],
294
+ verification: `Continue with: ${input.originalGoal.substring(0, 50)}`
295
+ };
296
+ }
297
+ /**
298
+ * Infer action type from prompt
299
+ */
300
+ export function inferAction(prompt) {
301
+ const lower = prompt.toLowerCase();
302
+ if (lower.includes('fix') || lower.includes('bug') || lower.includes('error')) {
303
+ return 'fix';
304
+ }
305
+ if (lower.includes('add') || lower.includes('create') || lower.includes('implement')) {
306
+ return 'add';
307
+ }
308
+ if (lower.includes('refactor') || lower.includes('improve') || lower.includes('clean')) {
309
+ return 'refactor';
310
+ }
311
+ if (lower.includes('test') || lower.includes('spec')) {
312
+ return 'test';
313
+ }
314
+ if (lower.includes('doc') || lower.includes('comment') || lower.includes('readme')) {
315
+ return 'document';
316
+ }
317
+ if (lower.includes('update') || lower.includes('change') || lower.includes('modify')) {
318
+ return 'update';
319
+ }
320
+ if (lower.includes('remove') || lower.includes('delete')) {
321
+ return 'remove';
322
+ }
323
+ return 'unknown';
324
+ }
325
+ // ============================================
326
+ // HELPERS
327
+ // ============================================
328
+ /**
329
+ * Map numeric score to drift type
330
+ */
331
+ function mapScoreToType(score) {
332
+ if (score >= 8)
333
+ return 'aligned';
334
+ if (score >= 6)
335
+ return 'minor';
336
+ if (score >= 4)
337
+ return 'moderate';
338
+ if (score >= 2)
339
+ return 'severe';
340
+ return 'critical';
341
+ }
@@ -0,0 +1,27 @@
1
+ interface HookCommand {
2
+ type: 'command';
3
+ command: string;
4
+ }
5
+ interface HookEntry {
6
+ matcher?: Record<string, unknown>;
7
+ hooks: HookCommand[];
8
+ }
9
+ interface ClaudeSettings {
10
+ hooks?: {
11
+ Stop?: HookEntry[];
12
+ SessionStart?: HookEntry[];
13
+ [key: string]: HookEntry[] | undefined;
14
+ };
15
+ [key: string]: unknown;
16
+ }
17
+ export declare function readClaudeSettings(): ClaudeSettings;
18
+ export declare function writeClaudeSettings(settings: ClaudeSettings): void;
19
+ export declare function registerGrovHooks(): {
20
+ added: string[];
21
+ alreadyExists: string[];
22
+ };
23
+ export declare function unregisterGrovHooks(): {
24
+ removed: string[];
25
+ };
26
+ export declare function getSettingsPath(): string;
27
+ export {};
@@ -0,0 +1,258 @@
1
+ // Helper to read/write ~/.claude/settings.json
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join, resolve, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ const CLAUDE_DIR = join(homedir(), '.claude');
7
+ const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
8
+ // Cache for grov path to avoid repeated file system checks
9
+ let cachedGrovPath = null;
10
+ // SECURITY: Pattern for safe grov executable paths (no shell metacharacters)
11
+ // Allows: alphanumeric, /, -, _, ., space, and quotes (for paths with spaces)
12
+ const SAFE_PATH_PATTERN = /^[a-zA-Z0-9/\-_. "]+$/;
13
+ /**
14
+ * Validate that a path doesn't contain shell metacharacters.
15
+ * SECURITY: Prevents command injection via malicious path names.
16
+ */
17
+ function isPathSafe(path) {
18
+ // Must match safe pattern
19
+ if (!SAFE_PATH_PATTERN.test(path)) {
20
+ return false;
21
+ }
22
+ // Must not contain shell dangerous sequences
23
+ const dangerousPatterns = [';', '|', '&', '`', '$', '(', ')', '<', '>', '\n', '\r'];
24
+ return !dangerousPatterns.some(p => path.includes(p));
25
+ }
26
+ /**
27
+ * Safe locations where grov executable can be found.
28
+ * We only check these known paths - no shell commands are executed.
29
+ */
30
+ const SAFE_GROV_LOCATIONS = [
31
+ '/opt/homebrew/bin/grov', // macOS ARM (Homebrew)
32
+ '/usr/local/bin/grov', // macOS Intel / Linux
33
+ '/usr/bin/grov', // System-wide Linux
34
+ join(homedir(), '.npm-global/bin/grov'), // Custom npm prefix
35
+ join(homedir(), '.local/bin/grov'), // Local user bin
36
+ ];
37
+ /**
38
+ * Get nvm-based paths for current and common Node versions.
39
+ * Returns paths without executing any shell commands.
40
+ */
41
+ function getNvmPaths() {
42
+ const nvmDir = join(homedir(), '.nvm/versions/node');
43
+ const paths = [];
44
+ // Add current Node version path
45
+ paths.push(join(nvmDir, process.version, 'bin/grov'));
46
+ // Try common LTS versions
47
+ const ltsVersions = ['v18', 'v20', 'v22'];
48
+ for (const ver of ltsVersions) {
49
+ try {
50
+ const versionDir = join(nvmDir, ver);
51
+ if (existsSync(versionDir)) {
52
+ // Find actual version directories
53
+ const entries = readdirSync(versionDir);
54
+ for (const entry of entries) {
55
+ paths.push(join(nvmDir, ver, entry, 'bin/grov'));
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ // Skip if can't read directory
61
+ }
62
+ }
63
+ return paths;
64
+ }
65
+ /**
66
+ * Find the absolute path to the grov executable.
67
+ * SECURITY: Only checks known safe locations - no shell command execution.
68
+ * SECURITY: Validates paths don't contain shell metacharacters.
69
+ * OPTIMIZED: Caches result to avoid repeated file system checks.
70
+ */
71
+ function findGrovPath() {
72
+ // Return cached path if available
73
+ if (cachedGrovPath) {
74
+ return cachedGrovPath;
75
+ }
76
+ // Check safe locations first
77
+ for (const p of SAFE_GROV_LOCATIONS) {
78
+ // SECURITY: Validate path is safe before using
79
+ if (existsSync(p) && isPathSafe(p)) {
80
+ cachedGrovPath = p;
81
+ return p;
82
+ }
83
+ }
84
+ // Check nvm locations
85
+ for (const p of getNvmPaths()) {
86
+ // SECURITY: Validate path is safe before using
87
+ if (existsSync(p) && isPathSafe(p)) {
88
+ cachedGrovPath = p;
89
+ return p;
90
+ }
91
+ }
92
+ // Check if running from source (development mode)
93
+ try {
94
+ const __filename = fileURLToPath(import.meta.url);
95
+ const __dirname = dirname(__filename);
96
+ const localCli = resolve(__dirname, '../../dist/cli.js');
97
+ // SECURITY: Validate development path is safe before using
98
+ if (existsSync(localCli) && isPathSafe(localCli)) {
99
+ cachedGrovPath = `node "${localCli}"`;
100
+ return cachedGrovPath;
101
+ }
102
+ }
103
+ catch {
104
+ // ESM import.meta not available, skip
105
+ }
106
+ // Fallback to just 'grov' - will work if it's in PATH
107
+ // Note: 'grov' is inherently safe (no special chars)
108
+ cachedGrovPath = 'grov';
109
+ return cachedGrovPath;
110
+ }
111
+ export function readClaudeSettings() {
112
+ if (!existsSync(SETTINGS_PATH)) {
113
+ return {};
114
+ }
115
+ try {
116
+ const content = readFileSync(SETTINGS_PATH, 'utf-8');
117
+ return JSON.parse(content);
118
+ }
119
+ catch {
120
+ console.error('Warning: Could not parse ~/.claude/settings.json');
121
+ return {};
122
+ }
123
+ }
124
+ export function writeClaudeSettings(settings) {
125
+ // Ensure .claude directory exists with restricted permissions
126
+ if (!existsSync(CLAUDE_DIR)) {
127
+ mkdirSync(CLAUDE_DIR, { recursive: true, mode: 0o700 });
128
+ }
129
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), { mode: 0o600 });
130
+ }
131
+ export function registerGrovHooks() {
132
+ const settings = readClaudeSettings();
133
+ const added = [];
134
+ const alreadyExists = [];
135
+ // Get absolute path to grov executable
136
+ const grovPath = findGrovPath();
137
+ // Initialize hooks object if it doesn't exist
138
+ if (!settings.hooks) {
139
+ settings.hooks = {};
140
+ }
141
+ // Helper to check if a grov command already exists (check for both relative and absolute paths)
142
+ const hasGrovCommand = (entries, commandSuffix) => {
143
+ if (!entries)
144
+ return false;
145
+ return entries.some(entry => entry.hooks.some(h => h.type === 'command' && h.command.endsWith(commandSuffix)));
146
+ };
147
+ // Register Stop hook for capture
148
+ // Note: Stop/SessionStart hooks don't use matcher (only tool-specific hooks do)
149
+ const stopCommand = `${grovPath} capture`;
150
+ if (!settings.hooks.Stop) {
151
+ settings.hooks.Stop = [];
152
+ }
153
+ if (!hasGrovCommand(settings.hooks.Stop, 'grov capture') && !hasGrovCommand(settings.hooks.Stop, stopCommand)) {
154
+ settings.hooks.Stop.push({
155
+ hooks: [{ type: 'command', command: stopCommand }]
156
+ });
157
+ added.push(`Stop → ${stopCommand}`);
158
+ }
159
+ else {
160
+ alreadyExists.push('Stop → grov capture');
161
+ }
162
+ // Register SessionStart hook for inject
163
+ const sessionStartCommand = `${grovPath} inject`;
164
+ if (!settings.hooks.SessionStart) {
165
+ settings.hooks.SessionStart = [];
166
+ }
167
+ if (!hasGrovCommand(settings.hooks.SessionStart, 'grov inject') && !hasGrovCommand(settings.hooks.SessionStart, sessionStartCommand)) {
168
+ settings.hooks.SessionStart.push({
169
+ hooks: [{ type: 'command', command: sessionStartCommand }]
170
+ });
171
+ added.push(`SessionStart → ${sessionStartCommand}`);
172
+ }
173
+ else {
174
+ alreadyExists.push('SessionStart → grov inject');
175
+ }
176
+ // Register UserPromptSubmit hook for continuous context injection
177
+ const promptInjectCommand = `${grovPath} prompt-inject`;
178
+ if (!settings.hooks.UserPromptSubmit) {
179
+ settings.hooks.UserPromptSubmit = [];
180
+ }
181
+ if (!hasGrovCommand(settings.hooks.UserPromptSubmit, 'grov prompt-inject') && !hasGrovCommand(settings.hooks.UserPromptSubmit, promptInjectCommand)) {
182
+ settings.hooks.UserPromptSubmit.push({
183
+ hooks: [{ type: 'command', command: promptInjectCommand }]
184
+ });
185
+ added.push(`UserPromptSubmit → ${promptInjectCommand}`);
186
+ }
187
+ else {
188
+ alreadyExists.push('UserPromptSubmit → grov prompt-inject');
189
+ }
190
+ writeClaudeSettings(settings);
191
+ return { added, alreadyExists };
192
+ }
193
+ export function unregisterGrovHooks() {
194
+ const settings = readClaudeSettings();
195
+ const removed = [];
196
+ // Helper to find and remove grov command entries (handles both relative and absolute paths)
197
+ const removeGrovCommands = (entries, commandSuffix) => {
198
+ if (!entries)
199
+ return undefined;
200
+ // Filter out entries that contain any grov command ending with the suffix
201
+ const filtered = entries.filter(entry => {
202
+ const hasCommand = entry.hooks.some(h => h.type === 'command' && h.command.endsWith(commandSuffix));
203
+ return !hasCommand;
204
+ });
205
+ return filtered.length > 0 ? filtered : undefined;
206
+ };
207
+ // Also handle old string format for cleanup
208
+ const removeOldFormat = (entries, commandSuffix) => {
209
+ return entries.filter(entry => {
210
+ if (typeof entry === 'string') {
211
+ return !entry.endsWith(commandSuffix);
212
+ }
213
+ return true;
214
+ });
215
+ };
216
+ if (settings.hooks?.Stop) {
217
+ const originalLength = settings.hooks.Stop.length;
218
+ // Remove new format (handles both 'grov capture' and '/path/to/grov capture')
219
+ const newFormatFiltered = removeGrovCommands(settings.hooks.Stop, 'grov capture');
220
+ // Also clean up old string format if present
221
+ const cleaned = removeOldFormat(newFormatFiltered || [], 'grov capture');
222
+ if (cleaned.length < originalLength) {
223
+ removed.push('Stop → grov capture');
224
+ }
225
+ settings.hooks.Stop = cleaned.length > 0 ? cleaned : undefined;
226
+ }
227
+ if (settings.hooks?.SessionStart) {
228
+ const originalLength = settings.hooks.SessionStart.length;
229
+ // Remove new format (handles both 'grov inject' and '/path/to/grov inject')
230
+ const newFormatFiltered = removeGrovCommands(settings.hooks.SessionStart, 'grov inject');
231
+ // Also clean up old string format if present
232
+ const cleaned = removeOldFormat(newFormatFiltered || [], 'grov inject');
233
+ if (cleaned.length < originalLength) {
234
+ removed.push('SessionStart → grov inject');
235
+ }
236
+ settings.hooks.SessionStart = cleaned.length > 0 ? cleaned : undefined;
237
+ }
238
+ if (settings.hooks?.UserPromptSubmit) {
239
+ const originalLength = settings.hooks.UserPromptSubmit.length;
240
+ // Remove new format (handles both 'grov prompt-inject' and '/path/to/grov prompt-inject')
241
+ const newFormatFiltered = removeGrovCommands(settings.hooks.UserPromptSubmit, 'grov prompt-inject');
242
+ // Also clean up old string format if present
243
+ const cleaned = removeOldFormat(newFormatFiltered || [], 'grov prompt-inject');
244
+ if (cleaned.length < originalLength) {
245
+ removed.push('UserPromptSubmit → grov prompt-inject');
246
+ }
247
+ settings.hooks.UserPromptSubmit = cleaned.length > 0 ? cleaned : undefined;
248
+ }
249
+ // Clean up empty hooks object
250
+ if (settings.hooks && Object.keys(settings.hooks).every(k => !settings.hooks[k])) {
251
+ delete settings.hooks;
252
+ }
253
+ writeClaudeSettings(settings);
254
+ return { removed };
255
+ }
256
+ export function getSettingsPath() {
257
+ return SETTINGS_PATH;
258
+ }