@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/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.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/
|
|
34
|
+
//#region src/constants/hookTypes.ts
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
-
*
|
|
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
|
|
188
|
+
* Parse Claude Code stdin into ClaudeCodeHookInput
|
|
143
189
|
*
|
|
144
190
|
* @param stdin - Raw JSON string from Claude Code
|
|
145
|
-
* @returns
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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/
|
|
254
|
+
//#region src/adapters/GeminiCliAdapter.ts
|
|
206
255
|
/**
|
|
207
|
-
*
|
|
256
|
+
* GeminiCliAdapter - Adapter for Gemini CLI hook format
|
|
208
257
|
*
|
|
209
258
|
* DESIGN PATTERNS:
|
|
210
|
-
* - Adapter pattern: Converts
|
|
211
|
-
* - Parser pattern: Extracts file paths and operations from tool
|
|
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
|
|
215
|
-
* - Format output to match
|
|
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
|
|
273
|
+
* Adapter for Gemini CLI hook format
|
|
225
274
|
*/
|
|
226
|
-
var
|
|
275
|
+
var GeminiCliAdapter = class extends BaseAdapter {
|
|
227
276
|
/**
|
|
228
|
-
* Parse
|
|
277
|
+
* Parse Gemini CLI stdin into full hook input (preserves all fields)
|
|
229
278
|
*
|
|
230
|
-
* @param stdin - Raw JSON string from
|
|
231
|
-
* @returns
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
289
|
+
* Format normalized HookResponse into Gemini CLI output
|
|
247
290
|
*
|
|
248
291
|
* @param response - Normalized hook response
|
|
249
|
-
* @returns JSON string for
|
|
292
|
+
* @returns JSON string for Gemini CLI
|
|
250
293
|
*/
|
|
251
294
|
formatOutput(response) {
|
|
252
|
-
|
|
253
|
-
if (response.decision ===
|
|
254
|
-
output.decision
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* @
|
|
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
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
397
|
+
async logExecution(params) {
|
|
343
398
|
const entry = {
|
|
344
399
|
timestamp: Date.now(),
|
|
345
|
-
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(
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
if (
|
|
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
|
-
|
|
368
|
-
if (
|
|
431
|
+
async loadLog() {
|
|
432
|
+
if (this.cache !== null) return this.cache;
|
|
369
433
|
try {
|
|
370
|
-
const lines = (await node_fs_promises.readFile(
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
return
|
|
444
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
445
|
+
this.cache = [];
|
|
446
|
+
return this.cache;
|
|
381
447
|
}
|
|
382
|
-
console.error(
|
|
383
|
-
|
|
384
|
-
return
|
|
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
|
-
|
|
457
|
+
async clearLog() {
|
|
391
458
|
try {
|
|
392
|
-
await node_fs_promises.unlink(
|
|
393
|
-
|
|
459
|
+
await node_fs_promises.unlink(this.logFile);
|
|
460
|
+
this.cache = [];
|
|
394
461
|
} catch (error) {
|
|
395
|
-
if (error.code
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
*
|
|
545
|
+
* Check if file was recently reviewed (within debounce window)
|
|
546
|
+
* Prevents noisy feedback during rapid successive edits
|
|
481
547
|
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|