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