@agiflowai/hooks-adapter 0.0.0 → 0.0.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.
package/dist/index.mjs CHANGED
@@ -1,19 +1,19 @@
1
+ import { log } from "@agiflowai/aicode-utils";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import * as os from "node:os";
4
5
  import * as crypto from "node:crypto";
5
- import { CLAUDE_CODE } from "@agiflowai/coding-agent-bridge";
6
6
 
7
- //#region src/constants/index.ts
7
+ //#region src/constants/hookTypes.ts
8
8
  /**
9
- * @agiflowai/hooks-adapter - Constants
9
+ * Hook Types Constants
10
10
  *
11
11
  * DESIGN PATTERNS:
12
12
  * - Strongly-typed constant exports for compile-time safety
13
13
  * - Immutable by default (as const assertions)
14
14
  *
15
15
  * CODING STANDARDS:
16
- * - Primitive constants: UPPER_SNAKE_CASE
16
+ * - Primitive constants: UPPER_SNAKE_CASE or PascalCase for event names
17
17
  * - Always include JSDoc with purpose and usage
18
18
  *
19
19
  * AVOID:
@@ -21,19 +21,36 @@ import { CLAUDE_CODE } from "@agiflowai/coding-agent-bridge";
21
21
  * - Magic strings without explanation
22
22
  */
23
23
  /**
24
- * Hook type identifiers for different hook events
24
+ * Hook type identifiers for Claude Code hook events
25
25
  */
26
26
  const PRE_TOOL_USE = "PreToolUse";
27
27
  const POST_TOOL_USE = "PostToolUse";
28
+ /**
29
+ * Hook type identifiers for Gemini CLI hook events
30
+ */
31
+ const BEFORE_TOOL_USE = "BeforeTool";
32
+ const AFTER_TOOL_USE = "AfterTool";
33
+
34
+ //#endregion
35
+ //#region src/constants/decisions.ts
36
+ /**
37
+ * Normalized hook decision types used across all adapters
38
+ * Adapters convert platform-specific formats to these normalized values
39
+ */
40
+ const DECISION_ALLOW = "allow";
41
+ const DECISION_DENY = "deny";
42
+ const DECISION_SKIP = "skip";
43
+ const DECISION_ASK = "ask";
28
44
 
29
45
  //#endregion
30
46
  //#region src/adapters/BaseAdapter.ts
31
47
  /**
32
- * Abstract base adapter for normalizing AI agent hook formats
48
+ * Abstract base adapter for AI agent hook formats
49
+ * @template TContext - The adapter-specific input context type
33
50
  */
34
51
  var BaseAdapter = class {
35
52
  /**
36
- * Execute hook callback with normalized context
53
+ * Execute hook callback with context
37
54
  * Template method that orchestrates the hook execution flow
38
55
  *
39
56
  * @param callback - Hook callback function to execute
@@ -54,6 +71,32 @@ var BaseAdapter = class {
54
71
  }
55
72
  }
56
73
  /**
74
+ * Execute multiple hooks with shared stdin (read once, execute all)
75
+ * This is useful when multiple hooks need to process the same input
76
+ * @param callbacks - Array of callback functions to execute
77
+ */
78
+ async executeMultiple(callbacks) {
79
+ try {
80
+ const stdin = await this.readStdin();
81
+ const context = this.parseInput(stdin);
82
+ const responses = [];
83
+ for (const callback of callbacks) {
84
+ const response = await callback(context);
85
+ responses.push(response);
86
+ }
87
+ const finalResponse = responses.find((r) => r.decision !== "skip");
88
+ if (!finalResponse) {
89
+ process.exit(0);
90
+ return;
91
+ }
92
+ const output = this.formatOutput(finalResponse);
93
+ console.log(output);
94
+ process.exit(0);
95
+ } catch (error) {
96
+ this.handleError(error);
97
+ }
98
+ }
99
+ /**
57
100
  * Read stdin from AI agent
58
101
  * @returns Promise resolving to stdin content
59
102
  */
@@ -91,16 +134,18 @@ var BaseAdapter = class {
91
134
  //#endregion
92
135
  //#region src/adapters/ClaudeCodeAdapter.ts
93
136
  /**
94
- * ClaudeCodeAdapter - Adapter for Claude Code hook format
137
+ * ClaudeCodeAdapter - Unified adapter for Claude Code hook format (PreToolUse & PostToolUse)
95
138
  *
96
139
  * DESIGN PATTERNS:
97
140
  * - Adapter pattern: Converts Claude Code format to normalized format
98
141
  * - Parser pattern: Extracts file paths and operations from tool inputs
142
+ * - State pattern: Stores hook event type to morph behavior between PreToolUse and PostToolUse
99
143
  *
100
144
  * CODING STANDARDS:
101
145
  * - Parse Claude Code JSON stdin format exactly as specified
102
146
  * - Format output to match Claude Code hook response schema
103
147
  * - Handle missing/optional fields gracefully
148
+ * - Support both PreToolUse and PostToolUse events in one adapter
104
149
  *
105
150
  * AVOID:
106
151
  * - Assuming all fields are present
@@ -108,84 +153,88 @@ var BaseAdapter = class {
108
153
  * - Mutating input objects
109
154
  */
110
155
  /**
111
- * Adapter for Claude Code hook format
156
+ * Unified adapter for Claude Code hook format (PreToolUse & PostToolUse)
112
157
  */
113
158
  var ClaudeCodeAdapter = class extends BaseAdapter {
159
+ hookEventName = "PreToolUse";
114
160
  /**
115
- * Parse Claude Code stdin into normalized HookContext
161
+ * Parse Claude Code stdin into ClaudeCodeHookInput
116
162
  *
117
163
  * @param stdin - Raw JSON string from Claude Code
118
- * @returns Normalized hook context
164
+ * @returns ClaudeCodeHookInput
119
165
  */
120
166
  parseInput(stdin) {
167
+ log.debug("ClaudeCodeAdapter: Parsing input", { stdin });
121
168
  const input = JSON.parse(stdin);
122
- return {
123
- toolName: input.tool_name,
124
- toolInput: input.tool_input,
125
- filePath: this.extractFilePath(input.tool_name, input.tool_input),
126
- operation: this.extractOperation(input.tool_name),
127
- cwd: input.cwd,
128
- sessionId: input.session_id,
129
- llmTool: input.llm_tool
130
- };
169
+ this.hookEventName = input.hook_event_name;
170
+ log.debug("ClaudeCodeAdapter: Parsed input", {
171
+ hookEventName: this.hookEventName,
172
+ input
173
+ });
174
+ return input;
131
175
  }
132
176
  /**
133
177
  * Format normalized HookResponse into Claude Code output
178
+ * Morphs output based on hook event type (PreToolUse vs PostToolUse)
134
179
  *
135
180
  * @param response - Normalized hook response
136
181
  * @returns JSON string for Claude Code
137
182
  */
138
183
  formatOutput(response) {
139
- if (response.decision === "skip") return JSON.stringify({}, null, 2);
184
+ log.debug("ClaudeCodeAdapter: Formatting output", {
185
+ hookEventName: this.hookEventName,
186
+ response
187
+ });
188
+ if (response.decision === "skip") {
189
+ const emptyOutput = JSON.stringify({}, null, 2);
190
+ log.debug("ClaudeCodeAdapter: Skip decision, returning empty output");
191
+ return emptyOutput;
192
+ }
193
+ if (this.hookEventName === "PostToolUse") return this.formatPostToolUseOutput(response);
194
+ return this.formatPreToolUseOutput(response);
195
+ }
196
+ /**
197
+ * Format PreToolUse output
198
+ */
199
+ formatPreToolUseOutput(response) {
140
200
  const output = { hookSpecificOutput: {
141
201
  hookEventName: "PreToolUse",
142
202
  permissionDecision: response.decision,
143
203
  permissionDecisionReason: response.message
144
204
  } };
145
205
  if (response.updatedInput) output.hookSpecificOutput.updatedInput = response.updatedInput;
146
- return JSON.stringify(output, null, 2);
206
+ const formattedOutput = JSON.stringify(output, null, 2);
207
+ log.debug("ClaudeCodeAdapter: Formatted PreToolUse output", { output: formattedOutput });
208
+ return formattedOutput;
147
209
  }
148
210
  /**
149
- * Extract file path from tool input
150
- *
151
- * @param toolName - Name of the tool
152
- * @param toolInput - Tool input parameters
153
- * @returns File path if this is a file operation
211
+ * Format PostToolUse output
154
212
  */
155
- extractFilePath(toolName, toolInput) {
156
- if ([
157
- "Read",
158
- "Write",
159
- "Edit"
160
- ].includes(toolName)) return toolInput.file_path;
161
- }
162
- /**
163
- * Extract operation type from tool name
164
- *
165
- * @param toolName - Name of the tool
166
- * @returns Operation type if this is a file operation
167
- */
168
- extractOperation(toolName) {
169
- return {
170
- Read: "read",
171
- Write: "write",
172
- Edit: "edit"
173
- }[toolName];
213
+ formatPostToolUseOutput(response) {
214
+ const output = { hookSpecificOutput: { hookEventName: "PostToolUse" } };
215
+ if (response.decision === "deny") {
216
+ output.decision = "block";
217
+ output.reason = response.message;
218
+ }
219
+ if (response.decision === "allow" && response.message) output.hookSpecificOutput.additionalContext = response.message;
220
+ const formattedOutput = JSON.stringify(output, null, 2);
221
+ log.debug("ClaudeCodeAdapter: Formatted PostToolUse output", { output: formattedOutput });
222
+ return formattedOutput;
174
223
  }
175
224
  };
176
225
 
177
226
  //#endregion
178
- //#region src/adapters/ClaudeCodePostToolUseAdapter.ts
227
+ //#region src/adapters/GeminiCliAdapter.ts
179
228
  /**
180
- * ClaudeCodePostToolUseAdapter - Adapter for Claude Code PostToolUse hook format
229
+ * GeminiCliAdapter - Adapter for Gemini CLI hook format
181
230
  *
182
231
  * DESIGN PATTERNS:
183
- * - Adapter pattern: Converts Claude Code PostToolUse format to normalized format
184
- * - Parser pattern: Extracts file paths and operations from tool response
232
+ * - Adapter pattern: Converts Gemini CLI format to normalized format
233
+ * - Parser pattern: Extracts file paths and operations from tool inputs
185
234
  *
186
235
  * CODING STANDARDS:
187
- * - Parse Claude Code JSON stdin format exactly as specified
188
- * - Format output to match Claude Code PostToolUse hook response schema
236
+ * - Parse Gemini CLI JSON stdin format exactly as specified
237
+ * - Format output to match Gemini CLI hook response schema
189
238
  * - Handle missing/optional fields gracefully
190
239
  *
191
240
  * AVOID:
@@ -194,69 +243,44 @@ var ClaudeCodeAdapter = class extends BaseAdapter {
194
243
  * - Mutating input objects
195
244
  */
196
245
  /**
197
- * Adapter for Claude Code PostToolUse hook format
246
+ * Adapter for Gemini CLI hook format
198
247
  */
199
- var ClaudeCodePostToolUseAdapter = class extends BaseAdapter {
248
+ var GeminiCliAdapter = class extends BaseAdapter {
200
249
  /**
201
- * Parse Claude Code PostToolUse stdin into normalized HookContext
250
+ * Parse Gemini CLI stdin into full hook input (preserves all fields)
202
251
  *
203
- * @param stdin - Raw JSON string from Claude Code
204
- * @returns Normalized hook context
252
+ * @param stdin - Raw JSON string from Gemini CLI
253
+ * @returns Full Gemini CLI hook input
205
254
  */
206
255
  parseInput(stdin) {
256
+ log.debug("GeminiCliAdapter.parseInput - Raw input:", stdin);
207
257
  const input = JSON.parse(stdin);
208
- return {
209
- toolName: input.tool_name,
210
- toolInput: input.tool_input,
211
- filePath: this.extractFilePath(input.tool_name, input.tool_input, input.tool_response),
212
- operation: this.extractOperation(input.tool_name),
213
- cwd: input.cwd,
214
- sessionId: input.session_id,
215
- llmTool: input.llm_tool
216
- };
258
+ log.debug("GeminiCliAdapter.parseInput - Parsed input:", JSON.stringify(input, null, 2));
259
+ return input;
217
260
  }
218
261
  /**
219
- * Format normalized HookResponse into Claude Code PostToolUse output
262
+ * Format normalized HookResponse into Gemini CLI output
220
263
  *
221
264
  * @param response - Normalized hook response
222
- * @returns JSON string for Claude Code
265
+ * @returns JSON string for Gemini CLI
223
266
  */
224
267
  formatOutput(response) {
225
- const output = { hookSpecificOutput: { hookEventName: "PostToolUse" } };
226
- if (response.decision === "deny") {
227
- output.decision = "block";
228
- output.reason = response.message;
268
+ log.debug("GeminiCliAdapter.formatOutput - Normalized response:", JSON.stringify(response, null, 2));
269
+ if (response.decision === DECISION_SKIP) {
270
+ const output$1 = JSON.stringify({ decision: "ALLOW" }, null, 2);
271
+ log.debug("GeminiCliAdapter.formatOutput - Output (skip):", output$1);
272
+ return output$1;
229
273
  }
230
- if (response.decision === "allow" && response.message) output.hookSpecificOutput.additionalContext = response.message;
231
- return JSON.stringify(output, null, 2);
232
- }
233
- /**
234
- * Extract file path from tool input or response
235
- *
236
- * @param toolName - Name of the tool
237
- * @param toolInput - Tool input parameters
238
- * @param toolResponse - Tool response data
239
- * @returns File path if this is a file operation
240
- */
241
- extractFilePath(toolName, toolInput, toolResponse) {
242
- if ([
243
- "Read",
244
- "Write",
245
- "Edit"
246
- ].includes(toolName)) return toolInput.file_path || toolResponse.filePath;
247
- }
248
- /**
249
- * Extract operation type from tool name
250
- *
251
- * @param toolName - Name of the tool
252
- * @returns Operation type if this is a file operation
253
- */
254
- extractOperation(toolName) {
255
- return {
256
- Read: "read",
257
- Write: "write",
258
- Edit: "edit"
259
- }[toolName];
274
+ const output = { decision: {
275
+ [DECISION_ALLOW]: "ALLOW",
276
+ [DECISION_DENY]: "DENY",
277
+ [DECISION_ASK]: "ASK_USER"
278
+ }[response.decision] || "ALLOW" };
279
+ if (response.message) output.message = response.message;
280
+ if (response.updatedInput) output.updatedInput = response.updatedInput;
281
+ const outputStr = JSON.stringify(output, null, 2);
282
+ log.debug("GeminiCliAdapter.formatOutput - Output:", outputStr);
283
+ return outputStr;
260
284
  }
261
285
  };
262
286
 
@@ -271,7 +295,7 @@ var ClaudeCodePostToolUseAdapter = class extends BaseAdapter {
271
295
  * - Singleton cache: In-memory cache for performance
272
296
  *
273
297
  * CODING STANDARDS:
274
- * - Use static methods for stateless operations
298
+ * - Use instance methods for session-scoped operations
275
299
  * - Handle file system errors gracefully
276
300
  * - Optimize for performance with efficient data structures
277
301
  *
@@ -281,53 +305,88 @@ var ClaudeCodePostToolUseAdapter = class extends BaseAdapter {
281
305
  * - Complex parsing logic (keep it simple)
282
306
  */
283
307
  /**
308
+ * Type guard for Node.js filesystem errors
309
+ */
310
+ function isNodeError(error) {
311
+ return error instanceof Error && "code" in error;
312
+ }
313
+ /**
284
314
  * Service for tracking hook executions using an append-only log
285
315
  * Prevents duplicate hook actions (e.g., showing design patterns twice for same file)
316
+ * Each session has its own log file for isolation
286
317
  */
287
318
  var ExecutionLogService = class ExecutionLogService {
288
- /** Log file path - stored in system temp directory */
289
- static LOG_FILE = path.join(os.tmpdir(), "hook-adapter-executions.jsonl");
319
+ /** Log file path for this session - stored in system temp directory */
320
+ logFile;
290
321
  /** In-memory cache of recent executions (last 1000 entries) */
291
- static cache = null;
322
+ cache = null;
292
323
  /** Max cache size to prevent memory bloat */
293
324
  static MAX_CACHE_SIZE = 1e3;
325
+ /** Session ID for this service instance */
326
+ sessionId;
327
+ /**
328
+ * Create a new ExecutionLogService instance for a specific session
329
+ * @param sessionId - Unique session identifier
330
+ */
331
+ constructor(sessionId) {
332
+ this.sessionId = sessionId;
333
+ this.logFile = path.join(os.tmpdir(), `hook-adapter-executions-${sessionId}.jsonl`);
334
+ }
294
335
  /**
295
336
  * Check if a specific action was already taken for this file in this session
296
337
  *
297
- * @param sessionId - Session identifier
298
- * @param filePath - File path to check
299
- * @param decision - Decision to check for (e.g., 'deny' means we already showed patterns)
300
- * @returns true if the action was already taken
338
+ * NOTE: Uses fail-open strategy - on error, returns false to allow action.
339
+ * This ensures hooks can still provide guidance even if log access fails.
340
+ *
341
+ * @param params - Parameters for checking execution
342
+ * @returns true if the action was already taken, false on error (fail-open)
301
343
  */
302
- static async hasExecuted(sessionId, filePath, decision) {
303
- const entries = await ExecutionLogService.loadLog();
304
- for (let i = entries.length - 1; i >= 0; i--) {
305
- const entry = entries[i];
306
- if (entry.sessionId === sessionId && entry.filePath === filePath && entry.decision === decision) return true;
344
+ async hasExecuted(params) {
345
+ const { filePath, decision, filePattern, projectPath } = params;
346
+ try {
347
+ const entries = await this.loadLog();
348
+ for (let i = entries.length - 1; i >= 0; i--) {
349
+ const entry = entries[i];
350
+ const matchedFile = entry.filePattern === filePattern && entry.filePattern && filePattern || entry.filePath === filePath;
351
+ const matchedProject = !projectPath || entry.projectPath === projectPath;
352
+ if (entry.decision === decision && matchedFile && matchedProject) return true;
353
+ }
354
+ return false;
355
+ } catch (error) {
356
+ console.error(`Error checking execution for ${filePath}:`, error);
357
+ return false;
307
358
  }
308
- return false;
309
359
  }
310
360
  /**
311
361
  * Log a hook execution
312
362
  *
313
- * @param params - Log execution parameters
363
+ * NOTE: This method uses fail-silent strategy. Logging failures should never
364
+ * break hook execution since the hook's primary purpose is to provide guidance,
365
+ * not to persist data. The log is used for optimization (preventing duplicate
366
+ * guidance) rather than critical functionality.
367
+ *
368
+ * @param params - Log execution parameters (sessionId will be set automatically)
314
369
  */
315
- static async logExecution(params) {
370
+ async logExecution(params) {
316
371
  const entry = {
317
372
  timestamp: Date.now(),
318
- sessionId: params.sessionId,
373
+ sessionId: this.sessionId,
319
374
  filePath: params.filePath,
320
375
  operation: params.operation,
321
376
  decision: params.decision,
322
377
  filePattern: params.filePattern,
323
378
  fileMtime: params.fileMtime,
324
- fileChecksum: params.fileChecksum
379
+ fileChecksum: params.fileChecksum,
380
+ generatedFiles: params.generatedFiles,
381
+ scaffoldId: params.scaffoldId,
382
+ projectPath: params.projectPath,
383
+ featureName: params.featureName
325
384
  };
326
385
  try {
327
- await fs.appendFile(ExecutionLogService.LOG_FILE, `${JSON.stringify(entry)}\n`, "utf-8");
328
- if (ExecutionLogService.cache) {
329
- ExecutionLogService.cache.push(entry);
330
- if (ExecutionLogService.cache.length > ExecutionLogService.MAX_CACHE_SIZE) ExecutionLogService.cache = ExecutionLogService.cache.slice(-ExecutionLogService.MAX_CACHE_SIZE);
386
+ await fs.appendFile(this.logFile, `${JSON.stringify(entry)}\n`, "utf-8");
387
+ if (this.cache) {
388
+ this.cache.push(entry);
389
+ if (this.cache.length > ExecutionLogService.MAX_CACHE_SIZE) this.cache = this.cache.slice(-ExecutionLogService.MAX_CACHE_SIZE);
331
390
  }
332
391
  } catch (error) {
333
392
  console.error("Failed to log hook execution:", error);
@@ -336,50 +395,73 @@ var ExecutionLogService = class ExecutionLogService {
336
395
  /**
337
396
  * Load execution log from file
338
397
  * Uses in-memory cache for performance
398
+ *
399
+ * NOTE: Uses fail-silent strategy for non-ENOENT errors. The log is used for
400
+ * optimization (deduplication) rather than critical functionality. If the log
401
+ * cannot be read, returning empty allows hooks to continue with potentially
402
+ * duplicate guidance rather than failing entirely.
339
403
  */
340
- static async loadLog() {
341
- if (ExecutionLogService.cache !== null) return ExecutionLogService.cache;
404
+ async loadLog() {
405
+ if (this.cache !== null) return this.cache;
342
406
  try {
343
- const lines = (await fs.readFile(ExecutionLogService.LOG_FILE, "utf-8")).trim().split("\n").filter(Boolean);
407
+ const lines = (await fs.readFile(this.logFile, "utf-8")).trim().split("\n").filter(Boolean);
344
408
  const entries = [];
345
409
  for (const line of lines) try {
346
410
  entries.push(JSON.parse(line));
347
- } catch {}
348
- ExecutionLogService.cache = entries.slice(-ExecutionLogService.MAX_CACHE_SIZE);
349
- return ExecutionLogService.cache;
411
+ } catch (parseError) {
412
+ console.warn("Skipping malformed log entry:", line.substring(0, 100));
413
+ }
414
+ this.cache = entries.slice(-ExecutionLogService.MAX_CACHE_SIZE);
415
+ return this.cache;
350
416
  } catch (error) {
351
- if (error.code === "ENOENT") {
352
- ExecutionLogService.cache = [];
353
- return ExecutionLogService.cache;
417
+ if (isNodeError(error) && error.code === "ENOENT") {
418
+ this.cache = [];
419
+ return this.cache;
354
420
  }
355
- console.error("Failed to load execution log:", error);
356
- ExecutionLogService.cache = [];
357
- return ExecutionLogService.cache;
421
+ console.error(`Failed to load execution log from ${this.logFile}:`, error);
422
+ this.cache = [];
423
+ return this.cache;
358
424
  }
359
425
  }
360
426
  /**
361
427
  * Clear the execution log (for testing)
428
+ * @throws Error if deletion fails for reasons other than file not existing
362
429
  */
363
- static async clearLog() {
430
+ async clearLog() {
364
431
  try {
365
- await fs.unlink(ExecutionLogService.LOG_FILE);
366
- ExecutionLogService.cache = [];
432
+ await fs.unlink(this.logFile);
433
+ this.cache = [];
367
434
  } catch (error) {
368
- if (error.code !== "ENOENT") throw error;
435
+ if (isNodeError(error) && error.code === "ENOENT") {
436
+ this.cache = [];
437
+ return;
438
+ }
439
+ throw new Error(`Failed to clear execution log at ${this.logFile}: ${error instanceof Error ? error.message : "Unknown error"}`);
369
440
  }
370
441
  }
371
442
  /**
372
443
  * Get log statistics (for debugging)
444
+ *
445
+ * NOTE: Uses fail-open strategy - on error, returns zero counts.
446
+ * This is acceptable for debugging statistics which are non-critical.
447
+ *
448
+ * @returns Log statistics, or zeros on error (fail-open)
373
449
  */
374
- static async getStats() {
375
- const entries = await ExecutionLogService.loadLog();
376
- const sessions = new Set(entries.map((e) => e.sessionId));
377
- const files = new Set(entries.map((e) => e.filePath));
378
- return {
379
- totalEntries: entries.length,
380
- uniqueSessions: sessions.size,
381
- uniqueFiles: files.size
382
- };
450
+ async getStats() {
451
+ try {
452
+ const entries = await this.loadLog();
453
+ const files = new Set(entries.map((e) => e.filePath));
454
+ return {
455
+ totalEntries: entries.length,
456
+ uniqueFiles: files.size
457
+ };
458
+ } catch (error) {
459
+ console.error("Error getting log stats:", error);
460
+ return {
461
+ totalEntries: 0,
462
+ uniqueFiles: 0
463
+ };
464
+ }
383
465
  }
384
466
  /**
385
467
  * Get file metadata (mtime and checksum) for a file
@@ -387,7 +469,7 @@ var ExecutionLogService = class ExecutionLogService {
387
469
  * @param filePath - Path to the file
388
470
  * @returns File metadata or null if file doesn't exist
389
471
  */
390
- static async getFileMetadata(filePath) {
472
+ async getFileMetadata(filePath) {
391
473
  try {
392
474
  const content = await fs.readFile(filePath, "utf-8");
393
475
  const checksum = crypto.createHash("md5").update(content).digest("hex");
@@ -395,7 +477,9 @@ var ExecutionLogService = class ExecutionLogService {
395
477
  mtime: (await fs.stat(filePath)).mtimeMs,
396
478
  checksum
397
479
  };
398
- } catch {
480
+ } catch (error) {
481
+ if (isNodeError(error) && error.code === "ENOENT") return null;
482
+ console.warn(`Failed to get file metadata for ${filePath}:`, error);
399
483
  return null;
400
484
  }
401
485
  }
@@ -403,78 +487,115 @@ var ExecutionLogService = class ExecutionLogService {
403
487
  * Check if a file has changed since the last execution for this session
404
488
  * Returns true if the file should be reviewed (new file or content changed)
405
489
  *
406
- * @param sessionId - Session identifier
490
+ * NOTE: Uses fail-open strategy - on error, returns true to allow review.
491
+ * This ensures hooks can still provide value even if log access fails.
492
+ *
407
493
  * @param filePath - File path to check
408
494
  * @param decision - Decision type to check for
409
- * @returns true if file has changed or no previous execution found
495
+ * @returns true if file has changed or no previous execution found, true on error (fail-open)
410
496
  */
411
- static async hasFileChanged(sessionId, filePath, decision) {
412
- const entries = await ExecutionLogService.loadLog();
413
- let lastExecution = null;
414
- for (let i = entries.length - 1; i >= 0; i--) {
415
- const entry = entries[i];
416
- if (entry.sessionId === sessionId && entry.filePath === filePath && entry.decision === decision) {
417
- lastExecution = entry;
418
- break;
497
+ async hasFileChanged(filePath, decision) {
498
+ try {
499
+ const entries = await this.loadLog();
500
+ let lastExecution = null;
501
+ for (let i = entries.length - 1; i >= 0; i--) {
502
+ const entry = entries[i];
503
+ if (entry.filePath === filePath && entry.decision === decision) {
504
+ lastExecution = entry;
505
+ break;
506
+ }
419
507
  }
508
+ if (!lastExecution || !lastExecution.fileChecksum) return true;
509
+ const currentMetadata = await this.getFileMetadata(filePath);
510
+ if (!currentMetadata) return true;
511
+ return currentMetadata.checksum !== lastExecution.fileChecksum;
512
+ } catch (error) {
513
+ console.error(`Error checking if file changed for ${filePath}:`, error);
514
+ return true;
420
515
  }
421
- if (!lastExecution || !lastExecution.fileChecksum) return true;
422
- const currentMetadata = await ExecutionLogService.getFileMetadata(filePath);
423
- if (!currentMetadata) return true;
424
- return currentMetadata.checksum !== lastExecution.fileChecksum;
425
516
  }
426
- };
427
-
428
- //#endregion
429
- //#region src/services/AdapterProxyService.ts
430
- /**
431
- * AdapterProxyService - Routes hook execution to appropriate adapter
432
- *
433
- * DESIGN PATTERNS:
434
- * - Proxy pattern: Routes requests to appropriate handlers
435
- * - Factory pattern: Creates adapter instances based on agent name
436
- *
437
- * CODING STANDARDS:
438
- * - Use static methods for stateless operations
439
- * - Provide clear error messages for invalid inputs
440
- * - Follow TitleCase naming convention for service classes
441
- *
442
- * AVOID:
443
- * - Creating adapter instances unnecessarily
444
- * - Silently falling back to defaults
445
- * - Complex conditional logic (use lookup tables)
446
- */
447
- /**
448
- * Proxy service for routing hook execution
449
- * Eliminates duplication across commands by centralizing hook routing logic
450
- */
451
- var AdapterProxyService = class AdapterProxyService {
452
517
  /**
453
- * Execute hook with the appropriate adapter for the agent
518
+ * Check if file was recently reviewed (within debounce window)
519
+ * Prevents noisy feedback during rapid successive edits
454
520
  *
455
- * @param agentName - Agent identifier (e.g., "claude-code")
456
- * @param hookType - Type of hook ("PreToolUse" or "PostToolUse")
457
- * @param callback - Hook callback function to execute
521
+ * NOTE: Uses fail-open strategy - on error, returns false to allow review.
522
+ * This ensures hooks can still provide value even if log access fails.
523
+ *
524
+ * @param filePath - File path to check
525
+ * @param debounceMs - Debounce window in milliseconds (default: 3000ms = 3 seconds)
526
+ * @returns true if file was reviewed within debounce window, false on error (fail-open)
458
527
  */
459
- static async execute(agentName, hookType, callback) {
460
- await AdapterProxyService.getAdapter(agentName, hookType).execute(callback);
528
+ async wasRecentlyReviewed(filePath, debounceMs = 3e3) {
529
+ try {
530
+ const entries = await this.loadLog();
531
+ const now = Date.now();
532
+ for (let i = entries.length - 1; i >= 0; i--) {
533
+ const entry = entries[i];
534
+ const isReviewOperation = entry.fileMtime !== void 0 || entry.fileChecksum !== void 0;
535
+ const isReviewDecision = entry.decision === "allow" || entry.decision === "deny";
536
+ if (entry.filePath === filePath && isReviewOperation && isReviewDecision) {
537
+ if (now - (entry.timestamp ?? 0) < debounceMs) return true;
538
+ return false;
539
+ }
540
+ }
541
+ return false;
542
+ } catch (error) {
543
+ console.error(`Error checking recent review for ${filePath}:`, error);
544
+ return false;
545
+ }
461
546
  }
462
547
  /**
463
- * Get adapter instance for agent and hook type
548
+ * Check if a file was generated by a scaffold method
549
+ * Useful for hooks to avoid suggesting scaffold for files already created by scaffold
464
550
  *
465
- * @param agentName - Name of the AI agent (e.g., "claude-code")
466
- * @param hookType - Type of hook ("PreToolUse" or "PostToolUse")
467
- * @returns Adapter instance
551
+ * NOTE: Uses fail-open strategy - on error, returns false to allow scaffold suggestion.
552
+ * Worst case: user sees scaffold suggestion for an already-scaffolded file.
553
+ *
554
+ * @param filePath - File path to check
555
+ * @returns true if file was generated by scaffold in this session, false on error (fail-open)
468
556
  */
469
- static getAdapter(agentName, hookType) {
470
- switch (agentName) {
471
- case CLAUDE_CODE:
472
- if (hookType === POST_TOOL_USE) return new ClaudeCodePostToolUseAdapter();
473
- return new ClaudeCodeAdapter();
474
- default: throw new Error(`Unknown agent: ${agentName}. Supported: ${CLAUDE_CODE}`);
557
+ async wasGeneratedByScaffold(filePath) {
558
+ try {
559
+ const entries = await this.loadLog();
560
+ for (let i = entries.length - 1; i >= 0; i--) {
561
+ const entry = entries[i];
562
+ if (entry.operation === "scaffold") {
563
+ if (entry.generatedFiles && entry.generatedFiles.includes(filePath)) return true;
564
+ }
565
+ }
566
+ return false;
567
+ } catch (error) {
568
+ console.error(`Error checking if file was generated by scaffold for ${filePath}:`, error);
569
+ return false;
475
570
  }
476
571
  }
477
572
  };
478
573
 
479
574
  //#endregion
480
- export { AdapterProxyService, BaseAdapter, ClaudeCodeAdapter, ClaudeCodePostToolUseAdapter, ExecutionLogService, POST_TOOL_USE, PRE_TOOL_USE };
575
+ //#region src/utils/parseHookType.ts
576
+ /**
577
+ * Parse hook type option in format: agent.hookMethod
578
+ *
579
+ * @param hookType - Hook type string in format '<agent>.<hookMethod>'
580
+ * @returns Parsed hook type with agent and hookMethod
581
+ * @throws Error if hook type format is invalid
582
+ *
583
+ * @example
584
+ * parseHookType('claude-code.preToolUse')
585
+ * // Returns: { agent: 'claude-code', hookMethod: 'preToolUse' }
586
+ *
587
+ * @example
588
+ * parseHookType('gemini-cli.afterTool')
589
+ * // Returns: { agent: 'gemini-cli', hookMethod: 'afterTool' }
590
+ */
591
+ function parseHookType(hookType) {
592
+ const [agent, hookMethod] = hookType.split(".");
593
+ if (!agent || !hookMethod) throw new Error(`Invalid hook type: ${hookType}. Expected: <agent>.<hookMethod>`);
594
+ return {
595
+ agent,
596
+ hookMethod
597
+ };
598
+ }
599
+
600
+ //#endregion
601
+ export { AFTER_TOOL_USE, BEFORE_TOOL_USE, BaseAdapter, ClaudeCodeAdapter, DECISION_ALLOW, DECISION_ASK, DECISION_DENY, DECISION_SKIP, ExecutionLogService, GeminiCliAdapter, POST_TOOL_USE, PRE_TOOL_USE, parseHookType };