@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/README.md +8 -8
- package/dist/index.cjs +337 -210
- package/dist/index.d.cts +316 -123
- package/dist/index.d.mts +316 -123
- package/dist/index.mjs +329 -208
- package/package.json +4 -2
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/
|
|
7
|
+
//#region src/constants/hookTypes.ts
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
-
*
|
|
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
|
|
161
|
+
* Parse Claude Code stdin into ClaudeCodeHookInput
|
|
116
162
|
*
|
|
117
163
|
* @param stdin - Raw JSON string from Claude Code
|
|
118
|
-
* @returns
|
|
164
|
+
* @returns ClaudeCodeHookInput
|
|
119
165
|
*/
|
|
120
166
|
parseInput(stdin) {
|
|
167
|
+
log.debug("ClaudeCodeAdapter: Parsing input", { stdin });
|
|
121
168
|
const input = JSON.parse(stdin);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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/
|
|
227
|
+
//#region src/adapters/GeminiCliAdapter.ts
|
|
179
228
|
/**
|
|
180
|
-
*
|
|
229
|
+
* GeminiCliAdapter - Adapter for Gemini CLI hook format
|
|
181
230
|
*
|
|
182
231
|
* DESIGN PATTERNS:
|
|
183
|
-
* - Adapter pattern: Converts
|
|
184
|
-
* - Parser pattern: Extracts file paths and operations from tool
|
|
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
|
|
188
|
-
* - Format output to match
|
|
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
|
|
246
|
+
* Adapter for Gemini CLI hook format
|
|
198
247
|
*/
|
|
199
|
-
var
|
|
248
|
+
var GeminiCliAdapter = class extends BaseAdapter {
|
|
200
249
|
/**
|
|
201
|
-
* Parse
|
|
250
|
+
* Parse Gemini CLI stdin into full hook input (preserves all fields)
|
|
202
251
|
*
|
|
203
|
-
* @param stdin - Raw JSON string from
|
|
204
|
-
* @returns
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
262
|
+
* Format normalized HookResponse into Gemini CLI output
|
|
220
263
|
*
|
|
221
264
|
* @param response - Normalized hook response
|
|
222
|
-
* @returns JSON string for
|
|
265
|
+
* @returns JSON string for Gemini CLI
|
|
223
266
|
*/
|
|
224
267
|
formatOutput(response) {
|
|
225
|
-
|
|
226
|
-
if (response.decision ===
|
|
227
|
-
output.decision
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* @
|
|
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
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
370
|
+
async logExecution(params) {
|
|
316
371
|
const entry = {
|
|
317
372
|
timestamp: Date.now(),
|
|
318
|
-
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(
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
if (
|
|
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
|
-
|
|
341
|
-
if (
|
|
404
|
+
async loadLog() {
|
|
405
|
+
if (this.cache !== null) return this.cache;
|
|
342
406
|
try {
|
|
343
|
-
const lines = (await fs.readFile(
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
return
|
|
417
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
418
|
+
this.cache = [];
|
|
419
|
+
return this.cache;
|
|
354
420
|
}
|
|
355
|
-
console.error(
|
|
356
|
-
|
|
357
|
-
return
|
|
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
|
-
|
|
430
|
+
async clearLog() {
|
|
364
431
|
try {
|
|
365
|
-
await fs.unlink(
|
|
366
|
-
|
|
432
|
+
await fs.unlink(this.logFile);
|
|
433
|
+
this.cache = [];
|
|
367
434
|
} catch (error) {
|
|
368
|
-
if (error.code
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
*
|
|
518
|
+
* Check if file was recently reviewed (within debounce window)
|
|
519
|
+
* Prevents noisy feedback during rapid successive edits
|
|
454
520
|
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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 };
|