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,451 @@
1
+ // grov prompt-inject - Called by UserPromptSubmit hook, outputs context JSON
2
+ // This provides continuous context injection on every user prompt
3
+ // Includes anti-drift detection and correction injection
4
+ //
5
+ // CRITICAL: We check Claude's ACTIONS, NOT user prompts.
6
+ // User can explore freely. We monitor what CLAUDE DOES.
7
+ import 'dotenv/config';
8
+ import { getTasksForProject, getTasksByFiles, getFileReasoningByPathPattern, getSessionState, createSessionState, updateSessionDrift, saveStep, updateLastChecked, } from '../lib/store.js';
9
+ import { extractIntent } from '../lib/llm-extractor.js';
10
+ import { buildDriftCheckInput, checkDrift } from '../lib/drift-checker.js';
11
+ import { determineCorrectionLevel, buildCorrection } from '../lib/correction-builder.js';
12
+ import { debugInject } from '../lib/debug.js';
13
+ import { truncate } from '../lib/utils.js';
14
+ import { findSessionFile, getNewActions, getModifyingActions, extractKeywordsFromAction, } from '../lib/session-parser.js';
15
+ // Maximum stdin size to prevent memory exhaustion (1MB)
16
+ const MAX_STDIN_SIZE = 1024 * 1024;
17
+ // Simple prompts that don't need context injection
18
+ const SIMPLE_PROMPTS = [
19
+ 'yes', 'no', 'ok', 'okay', 'continue', 'go ahead',
20
+ 'sure', 'yep', 'nope', 'y', 'n', 'proceed', 'do it',
21
+ 'looks good', 'that works', 'perfect', 'thanks', 'thank you',
22
+ 'next', 'done', 'good', 'great', 'fine', 'correct',
23
+ 'right', 'exactly', 'agreed', 'approve', 'confirm'
24
+ ];
25
+ // Stop words to filter out when extracting keywords
26
+ const STOP_WORDS = new Set([
27
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
28
+ 'to', 'for', 'and', 'or', 'in', 'on', 'at', 'of', 'with',
29
+ 'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you',
30
+ 'we', 'they', 'my', 'your', 'our', 'their', 'can', 'could',
31
+ 'would', 'should', 'will', 'do', 'does', 'did', 'have', 'has',
32
+ 'had', 'not', 'but', 'if', 'then', 'else', 'when', 'where',
33
+ 'how', 'what', 'why', 'which', 'who', 'all', 'each', 'every',
34
+ 'some', 'any', 'no', 'from', 'by', 'as', 'so', 'too', 'also',
35
+ 'just', 'only', 'now', 'here', 'there', 'please', 'help', 'me',
36
+ 'make', 'get', 'add', 'fix', 'update', 'change', 'modify', 'create'
37
+ ]);
38
+ export async function promptInject(_options) {
39
+ try {
40
+ // Read input from stdin
41
+ const input = await readStdinInput();
42
+ if (!input || !input.prompt) {
43
+ return; // No prompt, no injection
44
+ }
45
+ // Skip simple prompts to save tokens
46
+ if (isSimplePrompt(input.prompt)) {
47
+ return; // No output = no injection
48
+ }
49
+ const projectPath = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
50
+ const sessionId = input.session_id;
51
+ // Check if we have a session state (determines if this is first prompt)
52
+ const sessionState = sessionId ? getSessionState(sessionId) : null;
53
+ let correctionText = null;
54
+ // === FIRST PROMPT: Create session state with extracted intent ===
55
+ if (!sessionState && sessionId) {
56
+ correctionText = await handleFirstPrompt(input.prompt, projectPath, sessionId);
57
+ }
58
+ // === SUBSEQUENT PROMPTS: Check Claude's ACTIONS for drift ===
59
+ else if (sessionState) {
60
+ // CRITICAL: We pass projectPath, not prompt. We check Claude's ACTIONS.
61
+ correctionText = await handleDriftCheck(projectPath, sessionState);
62
+ }
63
+ // Get recent completed tasks for this project
64
+ const tasks = getTasksForProject(projectPath, {
65
+ status: 'complete',
66
+ limit: 20
67
+ });
68
+ // Find relevant tasks via file paths and keywords
69
+ const explicitFiles = extractFilePaths(input.prompt);
70
+ const fileTasks = explicitFiles.length > 0 && tasks.length > 0
71
+ ? getTasksByFiles(projectPath, explicitFiles, { status: 'complete', limit: 10 })
72
+ : [];
73
+ const keywordTasks = tasks.length > 0
74
+ ? findKeywordMatches(input.prompt, tasks)
75
+ : [];
76
+ // Also get file-level reasoning for mentioned files
77
+ const fileReasonings = explicitFiles.length > 0
78
+ ? explicitFiles.flatMap(f => getFileReasoningByPathPattern(f, 5))
79
+ : [];
80
+ // Combine and deduplicate tasks
81
+ const relevantTasks = dedupeAndLimit([...fileTasks, ...keywordTasks], 5);
82
+ // Build context (past reasoning from team memory)
83
+ const memoryContext = (relevantTasks.length > 0 || fileReasonings.length > 0)
84
+ ? buildPromptContext(relevantTasks, explicitFiles, fileReasonings)
85
+ : null;
86
+ // Combine correction and memory context
87
+ const combinedContext = buildCombinedContext(correctionText, memoryContext);
88
+ // Output if we have anything to inject
89
+ if (combinedContext) {
90
+ const output = {
91
+ hookSpecificOutput: {
92
+ hookEventName: "UserPromptSubmit",
93
+ additionalContext: combinedContext
94
+ }
95
+ };
96
+ console.log(JSON.stringify(output));
97
+ }
98
+ }
99
+ catch (error) {
100
+ // Silent fail - don't break user workflow
101
+ debugInject('prompt-inject error: %O', error);
102
+ }
103
+ }
104
+ /**
105
+ * Handle first prompt: extract intent and create session state
106
+ */
107
+ async function handleFirstPrompt(prompt, projectPath, sessionId) {
108
+ try {
109
+ debugInject('First prompt detected, extracting intent...');
110
+ // Extract intent from prompt
111
+ const intent = await extractIntent(prompt);
112
+ // Create session state with intent
113
+ createSessionState({
114
+ session_id: sessionId,
115
+ project_path: projectPath,
116
+ original_goal: intent.goal,
117
+ expected_scope: intent.expected_scope,
118
+ constraints: intent.constraints,
119
+ success_criteria: intent.success_criteria,
120
+ keywords: intent.keywords
121
+ });
122
+ debugInject('Session state created: goal=%s', intent.goal.substring(0, 50));
123
+ // No correction needed for first prompt
124
+ return null;
125
+ }
126
+ catch (error) {
127
+ debugInject('handleFirstPrompt error: %O', error);
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * Handle drift check for subsequent prompts.
133
+ *
134
+ * CRITICAL: We check Claude's ACTIONS, not user prompts.
135
+ * 1. Parse session JSONL to get Claude's recent actions
136
+ * 2. If no modifying actions, skip drift check (user just asked a question - OK!)
137
+ * 3. Build drift input from ACTIONS
138
+ * 4. Run drift check
139
+ * 5. Save steps and update last_checked_at
140
+ */
141
+ async function handleDriftCheck(projectPath, sessionState) {
142
+ try {
143
+ const sessionId = sessionState.session_id;
144
+ debugInject('Running drift check for session: %s', sessionId);
145
+ // 1. Find session JSONL file
146
+ const sessionPath = findSessionFile(sessionId, projectPath);
147
+ if (!sessionPath) {
148
+ debugInject('Session JSONL not found, skipping drift check');
149
+ return null;
150
+ }
151
+ // 2. Get Claude's actions since last check
152
+ const lastChecked = sessionState.last_checked_at || 0;
153
+ const claudeActions = getNewActions(sessionPath, lastChecked);
154
+ debugInject('Found %d new actions since last check', claudeActions.length);
155
+ // 3. If no new actions, user just asked a question - OK!
156
+ if (claudeActions.length === 0) {
157
+ debugInject('No new actions - user is exploring, not drift');
158
+ return null;
159
+ }
160
+ // 4. Filter to modifying actions only (read is always OK)
161
+ const modifyingActions = getModifyingActions(claudeActions);
162
+ if (modifyingActions.length === 0) {
163
+ debugInject('Only read actions - exploration, not drift');
164
+ // Still update last_checked to track progress
165
+ updateLastChecked(sessionId, Date.now());
166
+ return null;
167
+ }
168
+ // 5. Build drift input from ACTIONS (not prompt!)
169
+ const driftInput = buildDriftCheckInput(claudeActions, sessionId, sessionState);
170
+ // 6. Check drift
171
+ const driftResult = await checkDrift(driftInput);
172
+ debugInject('Drift check result: score=%d, type=%s', driftResult.score, driftResult.type);
173
+ // 7. Save steps to DB
174
+ for (const action of claudeActions) {
175
+ const isKeyDecision = driftResult.score >= 9 && action.type !== 'read';
176
+ const keywords = extractKeywordsFromAction(action);
177
+ saveStep(sessionId, action, driftResult.score, isKeyDecision, keywords);
178
+ }
179
+ // 8. Update last_checked timestamp
180
+ updateLastChecked(sessionId, Date.now());
181
+ // 9. Determine correction level
182
+ const level = determineCorrectionLevel(driftResult.score, sessionState.escalation_count);
183
+ debugInject('Correction level: %s', level || 'none');
184
+ // 10. Update session drift metrics
185
+ const actionsSummary = `${modifyingActions.length} modifying actions`;
186
+ updateSessionDrift(sessionId, driftResult.score, level, actionsSummary, driftResult.recoveryPlan);
187
+ // 11. Build correction if needed
188
+ if (level) {
189
+ return buildCorrection(driftResult, sessionState, level);
190
+ }
191
+ return null;
192
+ }
193
+ catch (error) {
194
+ debugInject('handleDriftCheck error: %O', error);
195
+ return null;
196
+ }
197
+ }
198
+ /**
199
+ * Combine correction and memory context
200
+ */
201
+ function buildCombinedContext(correction, memoryContext) {
202
+ if (!correction && !memoryContext) {
203
+ return null;
204
+ }
205
+ const parts = [];
206
+ // Correction comes first (most important)
207
+ if (correction) {
208
+ parts.push(correction);
209
+ }
210
+ // Memory context second
211
+ if (memoryContext) {
212
+ parts.push(memoryContext);
213
+ }
214
+ return parts.join('\n\n');
215
+ }
216
+ /**
217
+ * Read JSON input from stdin with timeout and size limit.
218
+ * SECURITY: Limits input size to prevent memory exhaustion attacks.
219
+ * OPTIMIZED: Uses array + join instead of O(n²) string concatenation.
220
+ * FIXED: Properly cleans up event listeners to prevent memory leaks.
221
+ */
222
+ async function readStdinInput() {
223
+ return new Promise((resolve) => {
224
+ const chunks = [];
225
+ let totalLength = 0;
226
+ let resolved = false;
227
+ // Cleanup function to remove all listeners
228
+ const cleanup = () => {
229
+ process.stdin.removeListener('readable', onReadable);
230
+ process.stdin.removeListener('end', onEnd);
231
+ process.stdin.removeListener('error', onError);
232
+ };
233
+ // Safe resolve that only resolves once and cleans up
234
+ const safeResolve = (value) => {
235
+ if (resolved)
236
+ return;
237
+ resolved = true;
238
+ clearTimeout(timeout);
239
+ cleanup();
240
+ resolve(value);
241
+ };
242
+ // Set a timeout to prevent hanging
243
+ const timeout = setTimeout(() => {
244
+ debugInject('stdin timeout reached');
245
+ safeResolve(null);
246
+ }, 3000); // 3 second timeout
247
+ process.stdin.setEncoding('utf-8');
248
+ const onReadable = () => {
249
+ let chunk;
250
+ while ((chunk = process.stdin.read()) !== null) {
251
+ totalLength += chunk.length;
252
+ // SECURITY: Check size limit
253
+ if (totalLength > MAX_STDIN_SIZE) {
254
+ debugInject('stdin size limit exceeded');
255
+ safeResolve(null);
256
+ return;
257
+ }
258
+ chunks.push(chunk);
259
+ }
260
+ };
261
+ const onEnd = () => {
262
+ try {
263
+ const data = chunks.join('');
264
+ const parsed = JSON.parse(data.trim());
265
+ // Validate required fields
266
+ if (!parsed || typeof parsed !== 'object') {
267
+ debugInject('Invalid stdin input: not an object');
268
+ safeResolve(null);
269
+ return;
270
+ }
271
+ const input = parsed;
272
+ if (typeof input.prompt !== 'string' || typeof input.cwd !== 'string') {
273
+ debugInject('Invalid stdin input: missing required fields');
274
+ safeResolve(null);
275
+ return;
276
+ }
277
+ safeResolve(input);
278
+ }
279
+ catch {
280
+ debugInject('Failed to parse stdin JSON');
281
+ safeResolve(null);
282
+ }
283
+ };
284
+ const onError = () => {
285
+ debugInject('stdin error');
286
+ safeResolve(null);
287
+ };
288
+ process.stdin.on('readable', onReadable);
289
+ process.stdin.on('end', onEnd);
290
+ process.stdin.on('error', onError);
291
+ });
292
+ }
293
+ /**
294
+ * Check if a prompt is simple and doesn't need context injection
295
+ */
296
+ function isSimplePrompt(prompt) {
297
+ const normalized = prompt.trim().toLowerCase();
298
+ // Very short prompts are likely simple
299
+ if (normalized.length < 3)
300
+ return true;
301
+ // Check against simple prompt list
302
+ for (const simple of SIMPLE_PROMPTS) {
303
+ if (normalized === simple ||
304
+ normalized.startsWith(simple + ' ') ||
305
+ normalized.startsWith(simple + ',') ||
306
+ normalized.startsWith(simple + '.')) {
307
+ return true;
308
+ }
309
+ }
310
+ // If the prompt is very short and doesn't contain code-related words
311
+ if (normalized.length < 20 && !normalized.match(/\.(ts|js|py|go|rs|java|tsx|jsx)/)) {
312
+ // Check if it's just a simple acknowledgment
313
+ const words = normalized.split(/\s+/);
314
+ if (words.length <= 3) {
315
+ return true;
316
+ }
317
+ }
318
+ return false;
319
+ }
320
+ // PERFORMANCE: Pre-compiled regexes for file path extraction
321
+ const TOKEN_SPLIT_REGEX = /[\s,;:'"``]+/;
322
+ const URL_PATTERN = /^https?:\/\//i;
323
+ const FILE_PATTERN = /^[.\/]?[\w\-\/]+\.\w{1,5}$/;
324
+ const VERSION_PATTERN = /^\d+\.\d+/;
325
+ /**
326
+ * Extract file paths from a prompt.
327
+ * SECURITY: Uses simplified patterns to avoid ReDoS with pathological input.
328
+ * PERFORMANCE: Uses pre-compiled regexes for efficiency.
329
+ */
330
+ function extractFilePaths(prompt) {
331
+ // SECURITY: Limit input length to prevent ReDoS
332
+ const safePrompt = prompt.length > 10000 ? prompt.substring(0, 10000) : prompt;
333
+ const files = new Set();
334
+ // Split by whitespace and common delimiters for simpler, safer matching
335
+ const tokens = safePrompt.split(TOKEN_SPLIT_REGEX);
336
+ for (const token of tokens) {
337
+ // Skip empty tokens and URLs
338
+ if (!token || URL_PATTERN.test(token))
339
+ continue;
340
+ // Match file-like patterns: must have extension
341
+ if (FILE_PATTERN.test(token)) {
342
+ // Filter out version numbers like 1.0.0
343
+ if (!VERSION_PATTERN.test(token)) {
344
+ files.add(token);
345
+ }
346
+ }
347
+ }
348
+ return [...files];
349
+ }
350
+ // SECURITY: Maximum keywords to prevent memory exhaustion
351
+ const MAX_KEYWORDS = 100;
352
+ /**
353
+ * Extract keywords from a prompt for matching against tasks.
354
+ * SECURITY: Limits keywords to MAX_KEYWORDS to prevent memory exhaustion.
355
+ */
356
+ function extractKeywords(prompt) {
357
+ const words = prompt.toLowerCase()
358
+ .replace(/[^\w\s]/g, ' ')
359
+ .split(/\s+/)
360
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w));
361
+ const uniqueWords = [...new Set(words)];
362
+ // SECURITY: Limit keywords to prevent memory exhaustion
363
+ return uniqueWords.length > MAX_KEYWORDS
364
+ ? uniqueWords.slice(0, MAX_KEYWORDS)
365
+ : uniqueWords;
366
+ }
367
+ /**
368
+ * Find tasks that match keywords from the prompt.
369
+ * OPTIMIZED: O(n) instead of O(n²) - builds keyword set once.
370
+ */
371
+ function findKeywordMatches(prompt, tasks) {
372
+ const keywords = extractKeywords(prompt);
373
+ if (keywords.length === 0) {
374
+ return [];
375
+ }
376
+ // Build keyword set for O(1) lookups
377
+ const keywordSet = new Set(keywords);
378
+ return tasks.filter(task => {
379
+ // Match against task tags - O(tags) lookup
380
+ const tagMatch = task.tags.some(tag => {
381
+ const lowerTag = tag.toLowerCase();
382
+ return keywordSet.has(lowerTag) ||
383
+ keywords.some(kw => lowerTag.includes(kw) || kw.includes(lowerTag));
384
+ });
385
+ if (tagMatch)
386
+ return true;
387
+ // Match against goal - extract once per task
388
+ const goalWords = (task.goal || '').toLowerCase().split(/\s+/).filter(w => w.length > 2);
389
+ const goalMatch = goalWords.some(gw => keywordSet.has(gw));
390
+ if (goalMatch)
391
+ return true;
392
+ // Match against original query - extract once per task
393
+ const queryWords = task.original_query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
394
+ const queryMatch = queryWords.some(qw => keywordSet.has(qw));
395
+ return queryMatch;
396
+ });
397
+ }
398
+ /**
399
+ * Deduplicate tasks by ID and limit count
400
+ */
401
+ function dedupeAndLimit(tasks, limit) {
402
+ const seen = new Set();
403
+ const unique = [];
404
+ for (const task of tasks) {
405
+ if (!seen.has(task.id)) {
406
+ seen.add(task.id);
407
+ unique.push(task);
408
+ if (unique.length >= limit)
409
+ break;
410
+ }
411
+ }
412
+ return unique;
413
+ }
414
+ /**
415
+ * Build context string for prompt injection
416
+ */
417
+ function buildPromptContext(tasks, files, fileReasonings) {
418
+ const lines = [];
419
+ lines.push('[GROV CONTEXT - Relevant past reasoning]');
420
+ lines.push('');
421
+ // Add file-specific reasoning if available
422
+ if (fileReasonings.length > 0) {
423
+ lines.push('File-level context:');
424
+ for (const fr of fileReasonings.slice(0, 5)) {
425
+ const anchor = fr.anchor ? ` (${fr.anchor})` : '';
426
+ lines.push(`- ${fr.file_path}${anchor}: ${truncate(fr.reasoning, 100)}`);
427
+ }
428
+ lines.push('');
429
+ }
430
+ // Add task context
431
+ if (tasks.length > 0) {
432
+ lines.push('Related past tasks:');
433
+ for (const task of tasks) {
434
+ lines.push(`- ${truncate(task.original_query, 60)}`);
435
+ if (task.files_touched.length > 0) {
436
+ const fileList = task.files_touched.slice(0, 3).map(f => f.split('/').pop()).join(', ');
437
+ lines.push(` Files: ${fileList}`);
438
+ }
439
+ if (task.reasoning_trace.length > 0) {
440
+ lines.push(` Key: ${truncate(task.reasoning_trace[0], 80)}`);
441
+ }
442
+ }
443
+ lines.push('');
444
+ }
445
+ // Add instruction
446
+ if (files.length > 0) {
447
+ lines.push(`You may already have context for: ${files.join(', ')}`);
448
+ }
449
+ lines.push('[END GROV CONTEXT]');
450
+ return lines.join('\n');
451
+ }
@@ -0,0 +1,5 @@
1
+ interface StatusOptions {
2
+ all?: boolean;
3
+ }
4
+ export declare function status(options: StatusOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,51 @@
1
+ // grov status - Show stored reasoning for current project
2
+ import { getTasksForProject, getTaskCount, getDatabasePath } from '../lib/store.js';
3
+ export async function status(options) {
4
+ const projectPath = process.cwd();
5
+ console.log('Grov Status');
6
+ console.log('===========\n');
7
+ console.log(`Project: ${projectPath}`);
8
+ console.log(`Database: ${getDatabasePath()}\n`);
9
+ // Get task count
10
+ const totalCount = getTaskCount(projectPath);
11
+ console.log(`Total tasks captured: ${totalCount}\n`);
12
+ if (totalCount === 0) {
13
+ console.log('No tasks captured yet for this project.');
14
+ console.log('Tasks will be captured automatically as you use Claude Code.');
15
+ return;
16
+ }
17
+ // Get tasks
18
+ const tasks = getTasksForProject(projectPath, {
19
+ status: options.all ? undefined : 'complete',
20
+ limit: 10
21
+ });
22
+ console.log(`Showing ${options.all ? 'all' : 'completed'} tasks (most recent ${tasks.length}):\n`);
23
+ for (const task of tasks) {
24
+ console.log(`[${task.status.toUpperCase()}] ${truncate(task.original_query, 60)}`);
25
+ console.log(` ID: ${task.id.substring(0, 8)}...`);
26
+ console.log(` Created: ${formatDate(task.created_at)}`);
27
+ if (task.files_touched.length > 0) {
28
+ const fileList = task.files_touched
29
+ .slice(0, 3)
30
+ .map(f => f.split('/').pop())
31
+ .join(', ');
32
+ console.log(` Files: ${fileList}${task.files_touched.length > 3 ? ` (+${task.files_touched.length - 3} more)` : ''}`);
33
+ }
34
+ if (task.tags.length > 0) {
35
+ console.log(` Tags: ${task.tags.join(', ')}`);
36
+ }
37
+ console.log('');
38
+ }
39
+ if (!options.all) {
40
+ console.log('Use --all to see all tasks (including partial/abandoned).');
41
+ }
42
+ }
43
+ function truncate(str, maxLength) {
44
+ if (str.length <= maxLength)
45
+ return str;
46
+ return str.substring(0, maxLength - 3) + '...';
47
+ }
48
+ function formatDate(isoString) {
49
+ const date = new Date(isoString);
50
+ return date.toLocaleString();
51
+ }
@@ -0,0 +1 @@
1
+ export declare function unregister(): Promise<void>;
@@ -0,0 +1,22 @@
1
+ // grov unregister - Remove hooks from Claude Code settings
2
+ import { unregisterGrovHooks, getSettingsPath } from '../lib/hooks.js';
3
+ export async function unregister() {
4
+ console.log('Removing grov hooks from Claude Code...\n');
5
+ try {
6
+ const { removed } = unregisterGrovHooks();
7
+ if (removed.length > 0) {
8
+ console.log('Removed hooks:');
9
+ removed.forEach(hook => console.log(` - ${hook}`));
10
+ }
11
+ else {
12
+ console.log('No grov hooks found to remove.');
13
+ }
14
+ console.log(`\nSettings file: ${getSettingsPath()}`);
15
+ console.log('\nGrov hooks have been disabled.');
16
+ console.log('Your stored reasoning data remains in ~/.grov/memory.db');
17
+ }
18
+ catch (error) {
19
+ console.error('Failed to unregister hooks:', error);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -0,0 +1,30 @@
1
+ export type AnchorType = 'function' | 'class' | 'method' | 'variable' | 'unknown';
2
+ export interface AnchorInfo {
3
+ type: AnchorType;
4
+ name: string;
5
+ lineStart: number;
6
+ lineEnd?: number;
7
+ }
8
+ /**
9
+ * Extract all anchors from a source file.
10
+ * PERFORMANCE: Uses single-pass O(n) algorithm instead of O(n²).
11
+ * SECURITY: Limits anchors to prevent DoS with pathological files.
12
+ */
13
+ export declare function extractAnchors(filePath: string, content: string): AnchorInfo[];
14
+ /**
15
+ * Find which anchor contains a given line number
16
+ */
17
+ export declare function findAnchorAtLine(anchors: AnchorInfo[], lineNumber: number): AnchorInfo | null;
18
+ /**
19
+ * Compute a hash of a code region for change detection.
20
+ * Uses SHA-256 (truncated) for security scanner compliance.
21
+ */
22
+ export declare function computeCodeHash(content: string, lineStart: number, lineEnd: number): string;
23
+ /**
24
+ * Estimate the line number where a string appears in content
25
+ */
26
+ export declare function estimateLineNumber(searchString: string, content: string): number | null;
27
+ /**
28
+ * Get a human-readable description of an anchor
29
+ */
30
+ export declare function describeAnchor(anchor: AnchorInfo): string;