grov 0.1.1 → 0.2.2

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/README.md +66 -87
  2. package/dist/cli.js +23 -37
  3. package/dist/commands/capture.js +1 -1
  4. package/dist/commands/disable.d.ts +1 -0
  5. package/dist/commands/disable.js +14 -0
  6. package/dist/commands/drift-test.js +56 -68
  7. package/dist/commands/init.js +29 -17
  8. package/dist/commands/proxy-status.d.ts +1 -0
  9. package/dist/commands/proxy-status.js +32 -0
  10. package/dist/commands/unregister.js +7 -1
  11. package/dist/lib/correction-builder-proxy.d.ts +16 -0
  12. package/dist/lib/correction-builder-proxy.js +125 -0
  13. package/dist/lib/correction-builder.js +1 -1
  14. package/dist/lib/drift-checker-proxy.d.ts +63 -0
  15. package/dist/lib/drift-checker-proxy.js +373 -0
  16. package/dist/lib/drift-checker.js +1 -1
  17. package/dist/lib/hooks.d.ts +11 -0
  18. package/dist/lib/hooks.js +33 -0
  19. package/dist/lib/llm-extractor.d.ts +60 -11
  20. package/dist/lib/llm-extractor.js +419 -98
  21. package/dist/lib/settings.d.ts +19 -0
  22. package/dist/lib/settings.js +63 -0
  23. package/dist/lib/store.d.ts +201 -43
  24. package/dist/lib/store.js +653 -90
  25. package/dist/proxy/action-parser.d.ts +58 -0
  26. package/dist/proxy/action-parser.js +196 -0
  27. package/dist/proxy/config.d.ts +26 -0
  28. package/dist/proxy/config.js +67 -0
  29. package/dist/proxy/forwarder.d.ts +24 -0
  30. package/dist/proxy/forwarder.js +119 -0
  31. package/dist/proxy/index.d.ts +1 -0
  32. package/dist/proxy/index.js +30 -0
  33. package/dist/proxy/request-processor.d.ts +12 -0
  34. package/dist/proxy/request-processor.js +94 -0
  35. package/dist/proxy/response-processor.d.ts +14 -0
  36. package/dist/proxy/response-processor.js +128 -0
  37. package/dist/proxy/server.d.ts +9 -0
  38. package/dist/proxy/server.js +911 -0
  39. package/package.json +10 -4
@@ -1,5 +1,5 @@
1
1
  // grov unregister - Remove hooks from Claude Code settings
2
- import { unregisterGrovHooks, getSettingsPath } from '../lib/hooks.js';
2
+ import { unregisterGrovHooks, getSettingsPath, setProxyEnv } from '../lib/hooks.js';
3
3
  export async function unregister() {
4
4
  console.log('Removing grov hooks from Claude Code...\n');
5
5
  try {
@@ -11,6 +11,12 @@ export async function unregister() {
11
11
  else {
12
12
  console.log('No grov hooks found to remove.');
13
13
  }
14
+ // Remove proxy URL from settings
15
+ const proxyResult = setProxyEnv(false);
16
+ if (proxyResult.action === 'removed') {
17
+ console.log('\nProxy configuration:');
18
+ console.log(' - ANTHROPIC_BASE_URL removed');
19
+ }
14
20
  console.log(`\nSettings file: ${getSettingsPath()}`);
15
21
  console.log('\nGrov hooks have been disabled.');
16
22
  console.log('Your stored reasoning data remains in ~/.grov/memory.db');
@@ -0,0 +1,16 @@
1
+ import type { SessionState, CorrectionLevel } from './store.js';
2
+ import type { DriftCheckResult } from './drift-checker-proxy.js';
3
+ export interface CorrectionMessage {
4
+ level: CorrectionLevel;
5
+ message: string;
6
+ mandatoryAction?: string;
7
+ }
8
+ /**
9
+ * Build correction message based on drift result and session state
10
+ * Reference: plan_proxy_local.md Section 4.3
11
+ */
12
+ export declare function buildCorrection(result: DriftCheckResult, sessionState: SessionState, level: CorrectionLevel): CorrectionMessage;
13
+ /**
14
+ * Format correction for system prompt injection
15
+ */
16
+ export declare function formatCorrectionForInjection(correction: CorrectionMessage): string;
@@ -0,0 +1,125 @@
1
+ // Correction builder for proxy - creates messages to inject when drift detected
2
+ // Reference: plan_proxy_local.md Section 4.3, 4.4
3
+ /**
4
+ * Build correction message based on drift result and session state
5
+ * Reference: plan_proxy_local.md Section 4.3
6
+ */
7
+ export function buildCorrection(result, sessionState, level) {
8
+ switch (level) {
9
+ case 'nudge':
10
+ return buildNudge(result, sessionState);
11
+ case 'correct':
12
+ return buildCorrect(result, sessionState);
13
+ case 'intervene':
14
+ return buildIntervene(result, sessionState);
15
+ case 'halt':
16
+ return buildHalt(result, sessionState);
17
+ }
18
+ }
19
+ /**
20
+ * NUDGE: Brief reminder (score 7)
21
+ */
22
+ function buildNudge(result, sessionState) {
23
+ const goal = sessionState.original_goal || 'the original task';
24
+ return {
25
+ level: 'nudge',
26
+ message: `<grov_nudge>
27
+ Quick reminder: Stay focused on ${goal}.
28
+ ${result.diagnostic}
29
+ </grov_nudge>`,
30
+ };
31
+ }
32
+ /**
33
+ * CORRECT: Full correction with recovery steps (score 5-6)
34
+ */
35
+ function buildCorrect(result, sessionState) {
36
+ const goal = sessionState.original_goal || 'the original task';
37
+ const scope = sessionState.expected_scope.length > 0
38
+ ? sessionState.expected_scope.join(', ')
39
+ : 'the relevant files';
40
+ let message = `<grov_correction>
41
+ DRIFT DETECTED - Please refocus on the original goal.
42
+
43
+ Original goal: ${goal}
44
+ Expected scope: ${scope}
45
+
46
+ Issue: ${result.diagnostic}
47
+ `;
48
+ if (result.suggestedAction) {
49
+ message += `\nSuggested action: ${result.suggestedAction}`;
50
+ }
51
+ if (result.recoverySteps && result.recoverySteps.length > 0) {
52
+ message += `\n\nRecovery steps:\n${result.recoverySteps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`;
53
+ }
54
+ message += `\n</grov_correction>`;
55
+ return {
56
+ level: 'correct',
57
+ message,
58
+ };
59
+ }
60
+ /**
61
+ * INTERVENE: Strong correction with mandatory first action (score 3-4)
62
+ */
63
+ function buildIntervene(result, sessionState) {
64
+ const goal = sessionState.original_goal || 'the original task';
65
+ const mandatoryAction = result.recoverySteps?.[0] || `Return to working on: ${goal}`;
66
+ return {
67
+ level: 'intervene',
68
+ message: `<grov_intervention>
69
+ SIGNIFICANT DRIFT - Intervention required.
70
+
71
+ You have strayed significantly from the original goal.
72
+
73
+ Original goal: ${goal}
74
+ Issue: ${result.diagnostic}
75
+
76
+ MANDATORY FIRST ACTION:
77
+ ${mandatoryAction}
78
+
79
+ You MUST execute this action before proceeding with anything else.
80
+ Confirm by stating: "I will now ${mandatoryAction}"
81
+ </grov_intervention>`,
82
+ mandatoryAction,
83
+ };
84
+ }
85
+ /**
86
+ * HALT: Critical stop with forced action (score 1-2)
87
+ */
88
+ function buildHalt(result, sessionState) {
89
+ const goal = sessionState.original_goal || 'the original task';
90
+ const mandatoryAction = result.recoverySteps?.[0] || `STOP and return to: ${goal}`;
91
+ const escalation = sessionState.escalation_count;
92
+ return {
93
+ level: 'halt',
94
+ message: `<grov_halt>
95
+ CRITICAL DRIFT - IMMEDIATE HALT REQUIRED
96
+
97
+ The current request has completely diverged from the original goal.
98
+ You MUST NOT proceed with the current request.
99
+
100
+ Original goal: ${goal}
101
+ Current alignment: ${result.score}/10 (CRITICAL)
102
+ Escalation level: ${escalation}/3${escalation >= 3 ? ' (MAX REACHED)' : ''}
103
+
104
+ Diagnostic: ${result.diagnostic}
105
+
106
+ MANDATORY FIRST ACTION:
107
+ You MUST execute ONLY this as your next action:
108
+ ${mandatoryAction}
109
+
110
+ ANY OTHER ACTION WILL DELAY YOUR GOAL.
111
+
112
+ CONFIRM by stating exactly:
113
+ "I will now ${mandatoryAction}"
114
+
115
+ DO NOT proceed with any other action until you have confirmed.
116
+ </grov_halt>`,
117
+ mandatoryAction,
118
+ };
119
+ }
120
+ /**
121
+ * Format correction for system prompt injection
122
+ */
123
+ export function formatCorrectionForInjection(correction) {
124
+ return `\n\n${correction.message}\n\n`;
125
+ }
@@ -176,7 +176,7 @@ function buildHalt(result, sessionState) {
176
176
  lines.push(`Diagnostic: ${result.diagnostic}`);
177
177
  lines.push('');
178
178
  // Show drift history if available
179
- if (sessionState.drift_history.length > 0) {
179
+ if (sessionState.drift_history && sessionState.drift_history.length > 0) {
180
180
  lines.push('Drift history in this session:');
181
181
  const recent = sessionState.drift_history.slice(-3);
182
182
  for (const event of recent) {
@@ -0,0 +1,63 @@
1
+ import type { SessionState, StepRecord, DriftType, CorrectionLevel } from './store.js';
2
+ export interface DriftCheckInput {
3
+ sessionState: SessionState;
4
+ recentSteps: StepRecord[];
5
+ latestUserMessage?: string;
6
+ }
7
+ export interface DriftCheckResult {
8
+ score: number;
9
+ driftType: DriftType;
10
+ diagnostic: string;
11
+ suggestedAction?: string;
12
+ recoverySteps?: string[];
13
+ }
14
+ /**
15
+ * Check if drift checking is available
16
+ */
17
+ export declare function isDriftCheckAvailable(): boolean;
18
+ /**
19
+ * Main drift check - uses LLM if available, fallback to basic
20
+ */
21
+ export declare function checkDrift(input: DriftCheckInput): Promise<DriftCheckResult>;
22
+ /**
23
+ * Basic drift check without LLM (fallback)
24
+ */
25
+ export declare function checkDriftBasic(input: DriftCheckInput): DriftCheckResult;
26
+ /**
27
+ * Convert score to correction level
28
+ * Reference: plan_proxy_local.md Section 4.3
29
+ */
30
+ export declare function scoreToCorrectionLevel(score: number): CorrectionLevel | null;
31
+ /**
32
+ * Check if score requires skipping steps table
33
+ * Reference: plan_proxy_local.md Section 4.2
34
+ */
35
+ export declare function shouldSkipSteps(score: number): boolean;
36
+ /**
37
+ * Check if Claude's action aligns with the recovery plan
38
+ * Returns true if aligned, false if still drifting
39
+ */
40
+ export declare function checkRecoveryAlignment(proposedAction: {
41
+ actionType: string;
42
+ files: string[];
43
+ command?: string;
44
+ }, recoveryPlan: {
45
+ steps: string[];
46
+ } | undefined, sessionState: SessionState): {
47
+ aligned: boolean;
48
+ reason: string;
49
+ };
50
+ export interface ForcedRecoveryResult {
51
+ recoveryPrompt: string;
52
+ mandatoryAction: string;
53
+ injectionText: string;
54
+ }
55
+ /**
56
+ * Generate forced recovery prompt using Haiku
57
+ * Called when escalation_count >= 3 (forced mode)
58
+ * This STOPS Claude and injects a specific recovery message
59
+ */
60
+ export declare function generateForcedRecovery(sessionState: SessionState, recentActions: Array<{
61
+ actionType: string;
62
+ files: string[];
63
+ }>, lastDriftResult: DriftCheckResult): Promise<ForcedRecoveryResult>;
@@ -0,0 +1,373 @@
1
+ // Drift checker for proxy - scores Claude's actions vs original goal
2
+ // Reference: plan_proxy_local.md Section 4.2, 4.3
3
+ import Anthropic from '@anthropic-ai/sdk';
4
+ let anthropicClient = null;
5
+ function getAnthropicClient() {
6
+ if (!anthropicClient) {
7
+ const apiKey = process.env.ANTHROPIC_API_KEY || process.env.GROV_API_KEY;
8
+ if (!apiKey) {
9
+ throw new Error('ANTHROPIC_API_KEY or GROV_API_KEY required for drift checking');
10
+ }
11
+ anthropicClient = new Anthropic({ apiKey });
12
+ }
13
+ return anthropicClient;
14
+ }
15
+ /**
16
+ * Check if drift checking is available
17
+ */
18
+ export function isDriftCheckAvailable() {
19
+ return !!(process.env.ANTHROPIC_API_KEY || process.env.GROV_API_KEY);
20
+ }
21
+ /**
22
+ * Main drift check - uses LLM if available, fallback to basic
23
+ */
24
+ export async function checkDrift(input) {
25
+ if (isDriftCheckAvailable()) {
26
+ try {
27
+ return await checkDriftWithLLM(input);
28
+ }
29
+ catch {
30
+ // Fallback to basic if LLM fails
31
+ return checkDriftBasic(input);
32
+ }
33
+ }
34
+ return checkDriftBasic(input);
35
+ }
36
+ /**
37
+ * LLM-based drift check using Haiku
38
+ * Reference: plan_proxy_local.md Section 3.1
39
+ */
40
+ async function checkDriftWithLLM(input) {
41
+ const client = getAnthropicClient();
42
+ const actionsText = input.recentSteps
43
+ .slice(-10)
44
+ .map(step => {
45
+ if (step.action_type === 'bash' && step.command) {
46
+ return `- ${step.action_type}: ${step.command.substring(0, 100)}`;
47
+ }
48
+ if (step.files.length > 0) {
49
+ return `- ${step.action_type}: ${step.files.join(', ')}`;
50
+ }
51
+ return `- ${step.action_type}`;
52
+ })
53
+ .join('\n');
54
+ // If we have a latest user message, that's the CURRENT instruction
55
+ const currentInstruction = input.latestUserMessage?.substring(0, 500) || '';
56
+ const hasCurrentInstruction = currentInstruction.length > 20;
57
+ const prompt = `You are a drift detection system. Check if Claude is following the user's CURRENT instructions.
58
+
59
+ ${hasCurrentInstruction ? `CURRENT USER INSTRUCTION (PRIMARY - check against this!):
60
+ "${currentInstruction}"
61
+
62
+ ORIGINAL SESSION GOAL (secondary context):
63
+ ${input.sessionState.original_goal || 'Not specified'}` : `ORIGINAL GOAL:
64
+ ${input.sessionState.original_goal || 'Not specified'}`}
65
+
66
+ EXPECTED SCOPE: ${input.sessionState.expected_scope.length > 0 ? input.sessionState.expected_scope.join(', ') : 'Not specified'}
67
+
68
+ CONSTRAINTS FROM USER: ${input.sessionState.constraints.length > 0 ? input.sessionState.constraints.join(', ') : 'None'}
69
+
70
+ CLAUDE'S RECENT ACTIONS:
71
+ ${actionsText || 'No actions yet'}
72
+
73
+ ═══════════════════════════════════════════════════════════════
74
+ CRITICAL: Compare Claude's actions against ${hasCurrentInstruction ? 'CURRENT USER INSTRUCTION' : 'ORIGINAL GOAL'}
75
+ ═══════════════════════════════════════════════════════════════
76
+
77
+ DRIFT = Claude doing something the user did NOT ask for, or IGNORING what user said.
78
+ NOT DRIFT = Claude following user's instructions, even if different from original goal.
79
+
80
+ Example:
81
+ - Original goal: "analyze the code"
82
+ - Current instruction: "now create the files"
83
+ - Claude creates files → NOT DRIFT (following current instruction)
84
+
85
+ CHECK FOR REAL DRIFT:
86
+ 1. Claude modifying files user said NOT to modify
87
+ 2. Claude ignoring explicit constraints ("don't run commands" but runs commands)
88
+ 3. Claude doing unrelated work (user asks about auth, Claude fixes CSS)
89
+ 4. Repetitive loops (editing same file 5+ times without progress)
90
+
91
+ Rate 1-10:
92
+ - 10: Perfect - following current instruction exactly
93
+ - 8-9: Good - on track with minor deviations
94
+ - 6-7: Moderate drift - needs nudge
95
+ - 4-5: Significant drift - ignoring parts of instruction
96
+ - 2-3: Major drift - doing opposite of what user asked
97
+ - 1: Critical - completely off track
98
+
99
+ RESPONSE RULES:
100
+ - English only
101
+ - No emojis
102
+ - Return JSON: {"score": N, "diagnostic": "brief reason", "suggestedAction": "what to do"}`;
103
+ const response = await client.messages.create({
104
+ model: 'claude-haiku-4-5-20251001',
105
+ max_tokens: 300,
106
+ messages: [{ role: 'user', content: prompt }],
107
+ });
108
+ const content = response.content?.[0];
109
+ if (!content || content.type !== 'text') {
110
+ return createDefaultResult(8, 'Could not parse LLM response');
111
+ }
112
+ return parseLLMResponse(content.text);
113
+ }
114
+ /**
115
+ * Basic drift check without LLM (fallback)
116
+ */
117
+ export function checkDriftBasic(input) {
118
+ let score = 8;
119
+ const diagnostics = [];
120
+ const { sessionState, recentSteps } = input;
121
+ const expectedScope = sessionState.expected_scope;
122
+ // Check if actions touch files outside scope
123
+ for (const step of recentSteps) {
124
+ if (step.action_type === 'read')
125
+ continue; // Read is OK
126
+ for (const file of step.files) {
127
+ if (expectedScope.length > 0) {
128
+ const inScope = expectedScope.some(scope => file.includes(scope));
129
+ if (!inScope) {
130
+ score -= 2;
131
+ diagnostics.push(`File outside scope: ${file}`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ // Check repetition (same file edited 3+ times recently)
137
+ const fileCounts = new Map();
138
+ for (const step of recentSteps) {
139
+ if (step.action_type === 'edit' || step.action_type === 'write') {
140
+ for (const file of step.files) {
141
+ fileCounts.set(file, (fileCounts.get(file) || 0) + 1);
142
+ }
143
+ }
144
+ }
145
+ for (const [file, count] of fileCounts) {
146
+ if (count >= 3) {
147
+ score -= 1;
148
+ diagnostics.push(`Repeated edits to ${file} (${count}x)`);
149
+ }
150
+ }
151
+ score = Math.max(1, Math.min(10, score));
152
+ return {
153
+ score,
154
+ driftType: scoreToDriftType(score),
155
+ diagnostic: diagnostics.length > 0 ? diagnostics.join('; ') : 'On track',
156
+ };
157
+ }
158
+ /**
159
+ * Parse LLM response JSON
160
+ */
161
+ function parseLLMResponse(text) {
162
+ try {
163
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
164
+ if (!jsonMatch) {
165
+ return createDefaultResult(8, 'No JSON in response');
166
+ }
167
+ const parsed = JSON.parse(jsonMatch[0]);
168
+ const score = typeof parsed.score === 'number'
169
+ ? Math.min(10, Math.max(1, parsed.score))
170
+ : 8;
171
+ return {
172
+ score,
173
+ driftType: scoreToDriftType(score),
174
+ diagnostic: typeof parsed.diagnostic === 'string' ? parsed.diagnostic : 'Unknown',
175
+ suggestedAction: typeof parsed.suggestedAction === 'string' ? parsed.suggestedAction : undefined,
176
+ recoverySteps: Array.isArray(parsed.recoverySteps)
177
+ ? parsed.recoverySteps.filter((s) => typeof s === 'string')
178
+ : undefined,
179
+ };
180
+ }
181
+ catch {
182
+ return createDefaultResult(8, 'Failed to parse response');
183
+ }
184
+ }
185
+ /**
186
+ * Convert score to drift type
187
+ */
188
+ function scoreToDriftType(score) {
189
+ if (score >= 8)
190
+ return 'none';
191
+ if (score >= 5)
192
+ return 'minor';
193
+ if (score >= 3)
194
+ return 'major';
195
+ return 'critical';
196
+ }
197
+ /**
198
+ * Convert score to correction level
199
+ * Reference: plan_proxy_local.md Section 4.3
200
+ */
201
+ export function scoreToCorrectionLevel(score) {
202
+ if (score >= 8)
203
+ return null;
204
+ if (score === 7)
205
+ return 'nudge';
206
+ if (score >= 5)
207
+ return 'correct';
208
+ if (score >= 3)
209
+ return 'intervene';
210
+ return 'halt';
211
+ }
212
+ /**
213
+ * Check if score requires skipping steps table
214
+ * Reference: plan_proxy_local.md Section 4.2
215
+ */
216
+ export function shouldSkipSteps(score) {
217
+ return score < 5;
218
+ }
219
+ /**
220
+ * Create default result
221
+ */
222
+ function createDefaultResult(score, diagnostic) {
223
+ return {
224
+ score,
225
+ driftType: scoreToDriftType(score),
226
+ diagnostic,
227
+ };
228
+ }
229
+ // ============================================
230
+ // RECOVERY ALIGNMENT CHECK
231
+ // Reference: plan_proxy_local.md Section 4.4
232
+ // ============================================
233
+ /**
234
+ * Check if Claude's action aligns with the recovery plan
235
+ * Returns true if aligned, false if still drifting
236
+ */
237
+ export function checkRecoveryAlignment(proposedAction, recoveryPlan, sessionState) {
238
+ if (!recoveryPlan || recoveryPlan.steps.length === 0) {
239
+ // No recovery plan - check if action is within scope
240
+ if (sessionState.expected_scope.length === 0) {
241
+ return { aligned: true, reason: 'No recovery plan or scope defined' };
242
+ }
243
+ // Check if files are in expected scope
244
+ const inScope = proposedAction.files.every(file => sessionState.expected_scope.some(scope => file.includes(scope)));
245
+ return {
246
+ aligned: inScope,
247
+ reason: inScope ? 'Files within expected scope' : 'Files outside expected scope',
248
+ };
249
+ }
250
+ const firstStep = recoveryPlan.steps[0].toLowerCase();
251
+ const actionDesc = `${proposedAction.actionType} ${proposedAction.files.join(' ')}`.toLowerCase();
252
+ // Check for keyword matches
253
+ const keywords = firstStep.split(/\s+/).filter(w => w.length > 3);
254
+ const matches = keywords.filter(kw => actionDesc.includes(kw) || proposedAction.files.some(f => f.toLowerCase().includes(kw)));
255
+ if (matches.length >= 2 || (matches.length >= 1 && proposedAction.files.length > 0)) {
256
+ return { aligned: true, reason: `Action matches recovery step: ${firstStep}` };
257
+ }
258
+ return { aligned: false, reason: `Expected: ${firstStep}, Got: ${actionDesc}` };
259
+ }
260
+ /**
261
+ * Generate forced recovery prompt using Haiku
262
+ * Called when escalation_count >= 3 (forced mode)
263
+ * This STOPS Claude and injects a specific recovery message
264
+ */
265
+ export async function generateForcedRecovery(sessionState, recentActions, lastDriftResult) {
266
+ const client = getAnthropicClient();
267
+ const actionsText = recentActions
268
+ .slice(-5)
269
+ .map(a => `- ${a.actionType}: ${a.files.join(', ')}`)
270
+ .join('\n');
271
+ const prompt = `You are helping recover a coding assistant that has COMPLETELY DRIFTED from its goal.
272
+
273
+ ORIGINAL GOAL: ${sessionState.original_goal || 'Not specified'}
274
+
275
+ EXPECTED SCOPE: ${sessionState.expected_scope.join(', ') || 'Not specified'}
276
+
277
+ CONSTRAINTS: ${sessionState.constraints.join(', ') || 'None'}
278
+
279
+ RECENT ACTIONS (all off-track):
280
+ ${actionsText || 'None recorded'}
281
+
282
+ DRIFT DIAGNOSTIC: ${lastDriftResult.diagnostic}
283
+
284
+ ESCALATION COUNT: ${sessionState.escalation_count} (MAX REACHED)
285
+
286
+ Generate a STRICT recovery message that will:
287
+ 1. STOP the assistant immediately
288
+ 2. FORCE it to acknowledge the drift
289
+ 3. Give ONE SPECIFIC, SIMPLE action to get back on track
290
+
291
+ RESPONSE RULES:
292
+ - English only
293
+ - No emojis
294
+ - Return JSON:
295
+ {
296
+ "recoveryPrompt": "The full message to inject (be firm but constructive, ~200 words)",
297
+ "mandatoryAction": "ONE specific action (e.g., 'Read src/auth/login.ts to refocus on authentication')"
298
+ }`;
299
+ const response = await client.messages.create({
300
+ model: 'claude-haiku-4-5-20251001',
301
+ max_tokens: 600,
302
+ messages: [{ role: 'user', content: prompt }],
303
+ });
304
+ const content = response.content?.[0];
305
+ if (!content || content.type !== 'text') {
306
+ return createFallbackForcedRecovery(sessionState);
307
+ }
308
+ try {
309
+ const jsonMatch = content.text.match(/\{[\s\S]*\}/);
310
+ if (!jsonMatch) {
311
+ return createFallbackForcedRecovery(sessionState);
312
+ }
313
+ const parsed = JSON.parse(jsonMatch[0]);
314
+ const recoveryPrompt = typeof parsed.recoveryPrompt === 'string'
315
+ ? parsed.recoveryPrompt
316
+ : `STOP. Return to: ${sessionState.original_goal}`;
317
+ const mandatoryAction = typeof parsed.mandatoryAction === 'string'
318
+ ? parsed.mandatoryAction
319
+ : `Focus on ${sessionState.original_goal}`;
320
+ return {
321
+ recoveryPrompt,
322
+ mandatoryAction,
323
+ injectionText: formatForcedRecoveryInjection(recoveryPrompt, mandatoryAction, sessionState),
324
+ };
325
+ }
326
+ catch {
327
+ return createFallbackForcedRecovery(sessionState);
328
+ }
329
+ }
330
+ /**
331
+ * Format forced recovery for system prompt injection
332
+ */
333
+ function formatForcedRecoveryInjection(recoveryPrompt, mandatoryAction, sessionState) {
334
+ return `
335
+
336
+ <grov_forced_recovery>
337
+ ════════════════════════════════════════════════════════════
338
+ *** CRITICAL: FORCED RECOVERY MODE ACTIVATED ***
339
+ ════════════════════════════════════════════════════════════
340
+
341
+ ${recoveryPrompt}
342
+
343
+ ────────────────────────────────────────────────────────────
344
+ MANDATORY FIRST ACTION (you MUST do this before ANYTHING else):
345
+ ${mandatoryAction}
346
+ ────────────────────────────────────────────────────────────
347
+
348
+ Original goal: ${sessionState.original_goal || 'See above'}
349
+ Escalation level: ${sessionState.escalation_count}/3 (MAXIMUM)
350
+
351
+ YOUR NEXT MESSAGE MUST:
352
+ 1. Acknowledge: "I understand I have drifted from the goal"
353
+ 2. State: "I will now ${mandatoryAction}"
354
+ 3. Execute ONLY that action
355
+
356
+ ANY OTHER RESPONSE WILL BE REJECTED.
357
+ ════════════════════════════════════════════════════════════
358
+ </grov_forced_recovery>
359
+
360
+ `;
361
+ }
362
+ /**
363
+ * Fallback forced recovery without LLM
364
+ */
365
+ function createFallbackForcedRecovery(sessionState) {
366
+ const goal = sessionState.original_goal || 'the original task';
367
+ const mandatoryAction = `Stop current work and return to: ${goal}`;
368
+ return {
369
+ recoveryPrompt: `You have completely drifted from your goal. Stop what you're doing immediately and refocus on: ${goal}`,
370
+ mandatoryAction,
371
+ injectionText: formatForcedRecoveryInjection(`You have completely drifted from your goal. Stop what you're doing immediately and refocus on: ${goal}`, mandatoryAction, sessionState),
372
+ };
373
+ }
@@ -38,7 +38,7 @@ export function buildDriftCheckInput(claudeActions, sessionId, sessionState) {
38
38
  expectedScope: sessionState.expected_scope,
39
39
  constraints: sessionState.constraints,
40
40
  keywords: sessionState.keywords,
41
- driftHistory: sessionState.drift_history.map(h => ({
41
+ driftHistory: (sessionState.drift_history || []).map(h => ({
42
42
  score: h.score,
43
43
  level: h.level
44
44
  })),
@@ -12,6 +12,10 @@ interface ClaudeSettings {
12
12
  SessionStart?: HookEntry[];
13
13
  [key: string]: HookEntry[] | undefined;
14
14
  };
15
+ env?: {
16
+ ANTHROPIC_BASE_URL?: string;
17
+ [key: string]: string | undefined;
18
+ };
15
19
  [key: string]: unknown;
16
20
  }
17
21
  export declare function readClaudeSettings(): ClaudeSettings;
@@ -24,4 +28,11 @@ export declare function unregisterGrovHooks(): {
24
28
  removed: string[];
25
29
  };
26
30
  export declare function getSettingsPath(): string;
31
+ /**
32
+ * Set or remove ANTHROPIC_BASE_URL in settings.json env section.
33
+ * This allows users to just type 'claude' instead of setting env var manually.
34
+ */
35
+ export declare function setProxyEnv(enable: boolean): {
36
+ action: 'added' | 'removed' | 'unchanged';
37
+ };
27
38
  export {};
package/dist/lib/hooks.js CHANGED
@@ -256,3 +256,36 @@ export function unregisterGrovHooks() {
256
256
  export function getSettingsPath() {
257
257
  return SETTINGS_PATH;
258
258
  }
259
+ /**
260
+ * Set or remove ANTHROPIC_BASE_URL in settings.json env section.
261
+ * This allows users to just type 'claude' instead of setting env var manually.
262
+ */
263
+ export function setProxyEnv(enable) {
264
+ const settings = readClaudeSettings();
265
+ const PROXY_URL = 'http://127.0.0.1:8080';
266
+ if (enable) {
267
+ // Add env.ANTHROPIC_BASE_URL
268
+ if (!settings.env) {
269
+ settings.env = {};
270
+ }
271
+ if (settings.env.ANTHROPIC_BASE_URL === PROXY_URL) {
272
+ return { action: 'unchanged' };
273
+ }
274
+ settings.env.ANTHROPIC_BASE_URL = PROXY_URL;
275
+ writeClaudeSettings(settings);
276
+ return { action: 'added' };
277
+ }
278
+ else {
279
+ // Remove env.ANTHROPIC_BASE_URL
280
+ if (!settings.env?.ANTHROPIC_BASE_URL) {
281
+ return { action: 'unchanged' };
282
+ }
283
+ delete settings.env.ANTHROPIC_BASE_URL;
284
+ // Clean up empty env object
285
+ if (Object.keys(settings.env).length === 0) {
286
+ delete settings.env;
287
+ }
288
+ writeClaudeSettings(settings);
289
+ return { action: 'removed' };
290
+ }
291
+ }