@dot-ai/adapter-claude 0.5.2 → 0.7.0

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tests for hook.ts logic patterns.
3
+ *
4
+ * hook.ts is a CLI entry point (self-executing, reads from stdin) so we cannot
5
+ * import its handlers directly. Instead we:
6
+ * 1. Re-implement the key regex logic and verify it matches the source patterns.
7
+ * 2. Test the handler behaviours by calling the equivalent logic through
8
+ * a thin reimplementation that accepts the same mocked DotAiRuntime.
9
+ *
10
+ * The regexes under test are the canonical ones from hook.ts:
11
+ * - Memory-file detection: /memory\/[^\s]*\.md$/i
12
+ * - Bash memory-write: /memory\/[^\s]*\.md/i + /(?:>|tee|cp|mv|cat\s*<<|echo\s.*>)/i
13
+ */
14
+ export {};
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Tests for hook.ts logic patterns.
3
+ *
4
+ * hook.ts is a CLI entry point (self-executing, reads from stdin) so we cannot
5
+ * import its handlers directly. Instead we:
6
+ * 1. Re-implement the key regex logic and verify it matches the source patterns.
7
+ * 2. Test the handler behaviours by calling the equivalent logic through
8
+ * a thin reimplementation that accepts the same mocked DotAiRuntime.
9
+ *
10
+ * The regexes under test are the canonical ones from hook.ts:
11
+ * - Memory-file detection: /memory\/[^\s]*\.md$/i
12
+ * - Bash memory-write: /memory\/[^\s]*\.md/i + /(?:>|tee|cp|mv|cat\s*<<|echo\s.*>)/i
13
+ */
14
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
15
+ import { ADAPTER_CAPABILITIES } from '@dot-ai/core';
16
+ // ── Regex constants (copied verbatim from hook.ts) ──────────────────────────
17
+ const MEMORY_FILE_RE = /memory\/[^\s]*\.md$/i;
18
+ const BASH_MEMORY_PATH_RE = /memory\/[^\s]*\.md/i;
19
+ const BASH_WRITE_OP_RE = /(?:>|tee|cp|mv|cat\s*<<|echo\s.*>)/i;
20
+ async function handlePreToolUse(event, runtime, out = []) {
21
+ const toolName = (event.tool_name ?? '');
22
+ const input = (event.tool_input ?? {});
23
+ // Extension check first
24
+ const extensionResult = await runtime.fireToolCall({ tool: toolName, input });
25
+ if (extensionResult?.decision === 'block') {
26
+ const payload = { decision: 'block', reason: extensionResult.reason ?? 'Blocked by extension' };
27
+ out.push(JSON.stringify(payload));
28
+ return payload;
29
+ }
30
+ // Hardcoded memory-file blocking
31
+ if (toolName === 'Write' || toolName === 'Edit') {
32
+ const filePath = (input.file_path ?? input.path ?? '');
33
+ if (filePath && MEMORY_FILE_RE.test(filePath)) {
34
+ const payload = {
35
+ decision: 'block',
36
+ reason: 'Memory is managed by dot-ai SQLite provider. Use the memory_store tool instead.',
37
+ };
38
+ out.push(JSON.stringify(payload));
39
+ return payload;
40
+ }
41
+ }
42
+ if (toolName === 'Bash') {
43
+ const command = (input.command ?? '');
44
+ if (BASH_MEMORY_PATH_RE.test(command) && BASH_WRITE_OP_RE.test(command)) {
45
+ const payload = {
46
+ decision: 'block',
47
+ reason: 'Memory is managed by dot-ai SQLite provider. Use the memory_store tool instead.',
48
+ };
49
+ out.push(JSON.stringify(payload));
50
+ return payload;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ async function handlePromptSubmit(event, runtime, out = []) {
56
+ const prompt = (event.prompt ?? event.content ?? '');
57
+ if (!prompt)
58
+ return;
59
+ const { formatted } = await runtime.processPrompt(prompt);
60
+ if (formatted) {
61
+ out.push(JSON.stringify({ result: formatted }));
62
+ }
63
+ }
64
+ async function handlePreCompact(event, runtime) {
65
+ const summary = (event.summary ?? event.content ?? '');
66
+ if (summary) {
67
+ await runtime.fire('agent_end', {
68
+ response: `[compaction] ${summary.slice(0, 1000)}`,
69
+ });
70
+ }
71
+ }
72
+ async function handleStop(event, runtime) {
73
+ const response = (event.response ?? event.content ?? '');
74
+ if (response) {
75
+ await runtime.learn(response);
76
+ }
77
+ }
78
+ function handleSessionStart(_event, runtime, errOut = []) {
79
+ const diag = runtime.diagnostics;
80
+ errOut.push(`[dot-ai] Booted\n`);
81
+ if (diag.extensions.length > 0) {
82
+ errOut.push(`[dot-ai] ${diag.extensions.length} extension(s), ${diag.capabilityCount} tool(s)\n`);
83
+ for (const ext of diag.extensions) {
84
+ for (const eventName of Object.keys(ext.handlerCounts)) {
85
+ if (!ADAPTER_CAPABILITIES['claude-code'].has(eventName)) {
86
+ errOut.push(`[dot-ai] Warning: Extension ${ext.path} uses '${eventName}' (not supported by Claude Code adapter)\n`);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ // ── Mock factory ─────────────────────────────────────────────────────────────
93
+ function makeRuntime() {
94
+ return {
95
+ fireToolCall: vi.fn().mockResolvedValue(null),
96
+ processPrompt: vi.fn().mockResolvedValue({ formatted: '', enriched: {}, capabilities: [] }),
97
+ learn: vi.fn().mockResolvedValue(undefined),
98
+ fire: vi.fn().mockResolvedValue([]),
99
+ diagnostics: { extensions: [], usedTiers: [], capabilityCount: 0, vocabularySize: 0 },
100
+ flush: vi.fn().mockResolvedValue(undefined),
101
+ boot: vi.fn().mockResolvedValue(undefined),
102
+ shutdown: vi.fn().mockResolvedValue(undefined),
103
+ capabilities: [],
104
+ isBooted: true,
105
+ runner: null,
106
+ };
107
+ }
108
+ // ── Tests ────────────────────────────────────────────────────────────────────
109
+ describe('MEMORY_FILE_RE', () => {
110
+ it('matches direct memory path', () => {
111
+ expect(MEMORY_FILE_RE.test('memory/notes.md')).toBe(true);
112
+ });
113
+ it('matches nested memory path', () => {
114
+ expect(MEMORY_FILE_RE.test('/home/user/.ai/memory/sub/dir/notes.md')).toBe(true);
115
+ });
116
+ it('matches case-insensitively', () => {
117
+ expect(MEMORY_FILE_RE.test('MEMORY/NOTES.MD')).toBe(true);
118
+ });
119
+ it('does not match non-md extension', () => {
120
+ expect(MEMORY_FILE_RE.test('memory/notes.txt')).toBe(false);
121
+ });
122
+ it('does not match path without memory segment', () => {
123
+ expect(MEMORY_FILE_RE.test('/project/src/notes.md')).toBe(false);
124
+ });
125
+ it('does not match when md is not at end', () => {
126
+ expect(MEMORY_FILE_RE.test('memory/notes.md.bak')).toBe(false);
127
+ });
128
+ it('does not match when filename has space before .md', () => {
129
+ expect(MEMORY_FILE_RE.test('memory/foo .md')).toBe(false);
130
+ });
131
+ });
132
+ describe('BASH_MEMORY_PATH_RE + BASH_WRITE_OP_RE', () => {
133
+ function isBashBlocked(cmd) {
134
+ return BASH_MEMORY_PATH_RE.test(cmd) && BASH_WRITE_OP_RE.test(cmd);
135
+ }
136
+ it('blocks redirect write (>)', () => {
137
+ expect(isBashBlocked('echo hello > memory/notes.md')).toBe(true);
138
+ });
139
+ it('blocks tee', () => {
140
+ expect(isBashBlocked('cat file.txt | tee memory/notes.md')).toBe(true);
141
+ });
142
+ it('blocks cp into memory', () => {
143
+ expect(isBashBlocked('cp backup.md memory/notes.md')).toBe(true);
144
+ });
145
+ it('blocks mv into memory', () => {
146
+ expect(isBashBlocked('mv tmp.md memory/notes.md')).toBe(true);
147
+ });
148
+ it('blocks cat heredoc', () => {
149
+ expect(isBashBlocked('cat << EOF > memory/notes.md')).toBe(true);
150
+ });
151
+ it('blocks echo with redirect', () => {
152
+ expect(isBashBlocked('echo "data" > memory/notes.md')).toBe(true);
153
+ });
154
+ it('allows cat read from memory (no write op)', () => {
155
+ expect(isBashBlocked('cat memory/notes.md')).toBe(false);
156
+ });
157
+ it('allows grep on memory files', () => {
158
+ expect(isBashBlocked('grep foo memory/notes.md')).toBe(false);
159
+ });
160
+ it('allows write op on non-memory path', () => {
161
+ expect(isBashBlocked('echo hello > /tmp/notes.md')).toBe(false);
162
+ });
163
+ });
164
+ describe('handlePreToolUse', () => {
165
+ let runtime;
166
+ let out;
167
+ beforeEach(() => {
168
+ runtime = makeRuntime();
169
+ out = [];
170
+ });
171
+ describe('extension-based blocking', () => {
172
+ it('blocks when extension returns decision=block', async () => {
173
+ vi.mocked(runtime.fireToolCall).mockResolvedValue({ decision: 'block', reason: 'No access' });
174
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: '/some/file.ts' } }, runtime, out);
175
+ expect(result).toMatchObject({ decision: 'block', reason: 'No access' });
176
+ expect(JSON.parse(out[0])).toMatchObject({ decision: 'block', reason: 'No access' });
177
+ });
178
+ it('uses fallback reason when extension provides none', async () => {
179
+ vi.mocked(runtime.fireToolCall).mockResolvedValue({ decision: 'block', reason: undefined });
180
+ const result = await handlePreToolUse({ tool_name: 'Read', tool_input: {} }, runtime, out);
181
+ expect(result).toMatchObject({ decision: 'block', reason: 'Blocked by extension' });
182
+ });
183
+ it('runs extension check BEFORE hardcoded memory check', async () => {
184
+ vi.mocked(runtime.fireToolCall).mockResolvedValue({ decision: 'block', reason: 'Custom extension block' });
185
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: 'memory/notes.md' } }, runtime, out);
186
+ expect(result).toMatchObject({ reason: 'Custom extension block' });
187
+ });
188
+ it('proceeds to hardcoded checks when extension returns null', async () => {
189
+ vi.mocked(runtime.fireToolCall).mockResolvedValue(null);
190
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: 'memory/notes.md' } }, runtime, out);
191
+ expect(result).toMatchObject({ decision: 'block' });
192
+ expect(result).toMatchObject({ reason: expect.stringContaining('SQLite') });
193
+ });
194
+ });
195
+ describe('Write tool blocking', () => {
196
+ it('blocks Write to memory/*.md via file_path', async () => {
197
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: 'memory/notes.md' } }, runtime, out);
198
+ expect(result).toMatchObject({ decision: 'block' });
199
+ expect(JSON.parse(out[0]).decision).toBe('block');
200
+ });
201
+ it('blocks Write to nested memory path', async () => {
202
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: '/home/user/.ai/memory/sub/log.md' } }, runtime, out);
203
+ expect(result).toMatchObject({ decision: 'block' });
204
+ });
205
+ it('blocks Write using .path field (alternative key)', async () => {
206
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { path: 'memory/notes.md' } }, runtime, out);
207
+ expect(result).toMatchObject({ decision: 'block' });
208
+ });
209
+ it('allows Write to non-memory path', async () => {
210
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: '/project/src/notes.md' } }, runtime, out);
211
+ expect(result).toBeNull();
212
+ expect(out).toHaveLength(0);
213
+ });
214
+ it('allows Write when file_path is empty', async () => {
215
+ const result = await handlePreToolUse({ tool_name: 'Write', tool_input: { file_path: '' } }, runtime, out);
216
+ expect(result).toBeNull();
217
+ });
218
+ });
219
+ describe('Edit tool blocking', () => {
220
+ it('blocks Edit to memory/*.md', async () => {
221
+ const result = await handlePreToolUse({ tool_name: 'Edit', tool_input: { file_path: 'memory/context.md' } }, runtime, out);
222
+ expect(result).toMatchObject({ decision: 'block' });
223
+ });
224
+ it('allows Edit to non-memory path', async () => {
225
+ const result = await handlePreToolUse({ tool_name: 'Edit', tool_input: { file_path: 'src/components/App.tsx' } }, runtime, out);
226
+ expect(result).toBeNull();
227
+ });
228
+ });
229
+ describe('Bash tool blocking', () => {
230
+ it('blocks echo redirect to memory file', async () => {
231
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'echo hello > memory/notes.md' } }, runtime, out);
232
+ expect(result).toMatchObject({ decision: 'block' });
233
+ });
234
+ it('blocks tee to memory file', async () => {
235
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'cat file | tee memory/notes.md' } }, runtime, out);
236
+ expect(result).toMatchObject({ decision: 'block' });
237
+ });
238
+ it('blocks cp into memory', async () => {
239
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'cp backup.md memory/notes.md' } }, runtime, out);
240
+ expect(result).toMatchObject({ decision: 'block' });
241
+ });
242
+ it('blocks mv into memory', async () => {
243
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'mv tmp.md memory/notes.md' } }, runtime, out);
244
+ expect(result).toMatchObject({ decision: 'block' });
245
+ });
246
+ it('allows cat read from memory (no write op)', async () => {
247
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'cat memory/notes.md' } }, runtime, out);
248
+ expect(result).toBeNull();
249
+ });
250
+ it('allows grep on memory file', async () => {
251
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'grep pattern memory/notes.md' } }, runtime, out);
252
+ expect(result).toBeNull();
253
+ });
254
+ it('allows write op to non-memory path', async () => {
255
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: 'echo hello > /tmp/output.md' } }, runtime, out);
256
+ expect(result).toBeNull();
257
+ });
258
+ it('allows Bash with empty command', async () => {
259
+ const result = await handlePreToolUse({ tool_name: 'Bash', tool_input: { command: '' } }, runtime, out);
260
+ expect(result).toBeNull();
261
+ });
262
+ });
263
+ describe('other tool names', () => {
264
+ it('allows unrecognised tool names without block', async () => {
265
+ const result = await handlePreToolUse({ tool_name: 'Read', tool_input: { file_path: 'memory/notes.md' } }, runtime, out);
266
+ expect(result).toBeNull();
267
+ });
268
+ });
269
+ });
270
+ describe('handlePromptSubmit', () => {
271
+ let runtime;
272
+ let out;
273
+ beforeEach(() => {
274
+ runtime = makeRuntime();
275
+ out = [];
276
+ });
277
+ it('calls processPrompt and writes JSON result to stdout', async () => {
278
+ vi.mocked(runtime.processPrompt).mockResolvedValue({
279
+ formatted: 'enriched context',
280
+ enriched: {},
281
+ capabilities: [],
282
+ });
283
+ await handlePromptSubmit({ prompt: 'how do I do X?' }, runtime, out);
284
+ expect(runtime.processPrompt).toHaveBeenCalledWith('how do I do X?');
285
+ expect(JSON.parse(out[0])).toEqual({ result: 'enriched context' });
286
+ });
287
+ it('reads prompt from content field if prompt is missing', async () => {
288
+ vi.mocked(runtime.processPrompt).mockResolvedValue({
289
+ formatted: 'ctx',
290
+ enriched: {},
291
+ capabilities: [],
292
+ });
293
+ await handlePromptSubmit({ content: 'alt prompt' }, runtime, out);
294
+ expect(runtime.processPrompt).toHaveBeenCalledWith('alt prompt');
295
+ });
296
+ it('does not write to stdout when formatted is empty', async () => {
297
+ vi.mocked(runtime.processPrompt).mockResolvedValue({
298
+ formatted: '',
299
+ enriched: {},
300
+ capabilities: [],
301
+ });
302
+ await handlePromptSubmit({ prompt: 'hello' }, runtime, out);
303
+ expect(out).toHaveLength(0);
304
+ });
305
+ it('returns early without calling processPrompt when prompt is empty', async () => {
306
+ await handlePromptSubmit({ prompt: '' }, runtime, out);
307
+ expect(runtime.processPrompt).not.toHaveBeenCalled();
308
+ expect(out).toHaveLength(0);
309
+ });
310
+ it('returns early when neither prompt nor content present', async () => {
311
+ await handlePromptSubmit({}, runtime, out);
312
+ expect(runtime.processPrompt).not.toHaveBeenCalled();
313
+ });
314
+ });
315
+ describe('handlePreCompact', () => {
316
+ it('fires agent_end with compaction summary', async () => {
317
+ const fire = vi.fn().mockResolvedValue([]);
318
+ const runtime = { fire };
319
+ await handlePreCompact({ summary: 'session wrapped up nicely' }, runtime);
320
+ expect(fire).toHaveBeenCalledWith('agent_end', {
321
+ response: '[compaction] session wrapped up nicely',
322
+ });
323
+ });
324
+ it('truncates summary to 1000 characters', async () => {
325
+ const fire = vi.fn().mockResolvedValue([]);
326
+ const runtime = { fire };
327
+ const longSummary = 'x'.repeat(2000);
328
+ await handlePreCompact({ summary: longSummary }, runtime);
329
+ const call = fire.mock.calls[0][1];
330
+ expect(call.response.length).toBe('[compaction] '.length + 1000);
331
+ });
332
+ it('reads from content field when summary is absent', async () => {
333
+ const fire = vi.fn().mockResolvedValue([]);
334
+ const runtime = { fire };
335
+ await handlePreCompact({ content: 'fallback summary' }, runtime);
336
+ expect(fire).toHaveBeenCalledWith('agent_end', {
337
+ response: '[compaction] fallback summary',
338
+ });
339
+ });
340
+ it('does nothing when summary is empty', async () => {
341
+ const fire = vi.fn().mockResolvedValue([]);
342
+ const runtime = { fire };
343
+ await handlePreCompact({ summary: '' }, runtime);
344
+ expect(fire).not.toHaveBeenCalled();
345
+ });
346
+ });
347
+ describe('handleStop', () => {
348
+ let runtime;
349
+ beforeEach(() => {
350
+ runtime = makeRuntime();
351
+ });
352
+ it('calls runtime.learn with the response', async () => {
353
+ await handleStop({ response: 'I completed the task.' }, runtime);
354
+ expect(runtime.learn).toHaveBeenCalledWith('I completed the task.');
355
+ });
356
+ it('reads from content field when response is absent', async () => {
357
+ await handleStop({ content: 'alt response' }, runtime);
358
+ expect(runtime.learn).toHaveBeenCalledWith('alt response');
359
+ });
360
+ it('does not call learn when response is empty', async () => {
361
+ await handleStop({ response: '' }, runtime);
362
+ expect(runtime.learn).not.toHaveBeenCalled();
363
+ });
364
+ it('does not call learn when neither response nor content present', async () => {
365
+ await handleStop({}, runtime);
366
+ expect(runtime.learn).not.toHaveBeenCalled();
367
+ });
368
+ });
369
+ describe('handleSessionStart', () => {
370
+ it('logs extension count when extensions are loaded', () => {
371
+ const errOut = [];
372
+ const runtime = {
373
+ ...makeRuntime(),
374
+ diagnostics: {
375
+ extensions: [
376
+ { path: '/ext/my-ext.js', handlerCounts: { prompt_submit: 1 }, toolNames: [], commandNames: [], tiers: [] },
377
+ ],
378
+ usedTiers: [],
379
+ capabilityCount: 0,
380
+ vocabularySize: 0,
381
+ },
382
+ };
383
+ handleSessionStart({}, runtime, errOut);
384
+ expect(errOut.some(l => l.includes('1 extension(s)'))).toBe(true);
385
+ });
386
+ it('warns about events not supported by Claude Code adapter', () => {
387
+ const errOut = [];
388
+ const unsupportedEvent = '__definitely_not_supported__';
389
+ const runtime = {
390
+ ...makeRuntime(),
391
+ diagnostics: {
392
+ extensions: [
393
+ { path: '/ext/my-ext.js', handlerCounts: { [unsupportedEvent]: 1 }, toolNames: [], commandNames: [], tiers: [] },
394
+ ],
395
+ usedTiers: [],
396
+ capabilityCount: 0,
397
+ vocabularySize: 0,
398
+ },
399
+ };
400
+ handleSessionStart({}, runtime, errOut);
401
+ const warnings = errOut.filter(l => l.includes('Warning'));
402
+ expect(warnings.length).toBeGreaterThan(0);
403
+ expect(warnings[0]).toContain(unsupportedEvent);
404
+ expect(warnings[0]).toContain('/ext/my-ext.js');
405
+ });
406
+ it('does not warn for events that ARE supported', () => {
407
+ const errOut = [];
408
+ const supportedEvent = [...ADAPTER_CAPABILITIES['claude-code']][0];
409
+ const runtime = {
410
+ ...makeRuntime(),
411
+ diagnostics: {
412
+ extensions: [
413
+ { path: '/ext/my-ext.js', handlerCounts: { [supportedEvent]: 1 }, toolNames: [], commandNames: [], tiers: [] },
414
+ ],
415
+ usedTiers: [],
416
+ capabilityCount: 0,
417
+ vocabularySize: 0,
418
+ },
419
+ };
420
+ handleSessionStart({}, runtime, errOut);
421
+ const warnings = errOut.filter(l => l.includes('Warning'));
422
+ expect(warnings).toHaveLength(0);
423
+ });
424
+ it('logs boot message when no extensions are loaded', () => {
425
+ const errOut = [];
426
+ handleSessionStart({}, makeRuntime(), errOut);
427
+ expect(errOut.some(l => l.includes('Booted'))).toBe(true);
428
+ expect(errOut.filter(l => l.includes('extension')).length).toBe(0);
429
+ });
430
+ });
431
+ describe('ADAPTER_CAPABILITIES claude-code set', () => {
432
+ it('is a Set', () => {
433
+ expect(ADAPTER_CAPABILITIES['claude-code']).toBeInstanceOf(Set);
434
+ });
435
+ it('contains expected event names', () => {
436
+ const set = ADAPTER_CAPABILITIES['claude-code'];
437
+ expect(set.size).toBeGreaterThan(0);
438
+ });
439
+ });
package/dist/hook.js CHANGED
@@ -1,69 +1,174 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * dot-ai hook for Claude Code.
4
- * Receives hook event JSON on stdin, runs the enrich pipeline,
5
- * returns enriched context on stdout.
4
+ * Dispatches by event type via CLI arg.
6
5
  *
7
6
  * Usage in hooks.json:
8
- * { "type": "command", "command": "node /path/to/hook.js" }
7
+ * "SessionStart": [{ "type": "command", "command": "node hook.js session-start" }]
8
+ * "UserPromptSubmit": [{ "type": "command", "command": "node hook.js prompt-submit" }]
9
+ * "PreCompact": [{ "type": "command", "command": "node hook.js pre-compact" }]
10
+ * "Stop": [{ "type": "command", "command": "node hook.js stop" }]
11
+ * "PreToolUse": [{ "type": "command", "command": "node hook.js pre-tool-use" }]
9
12
  */
10
- import { loadConfig, registerDefaults, createProviders, boot, enrich, injectRoot, formatContext, NoopLogger, JsonFileLogger } from '@dot-ai/core';
11
- async function main() {
12
- // Read event from stdin
13
+ import { DotAiRuntime, NoopLogger, JsonFileLogger, loadConfig, ADAPTER_CAPABILITIES, } from '@dot-ai/core';
14
+ // ── Shared ──
15
+ async function readStdin() {
13
16
  const chunks = [];
14
17
  for await (const chunk of process.stdin) {
15
18
  chunks.push(chunk);
16
19
  }
20
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'));
21
+ }
22
+ async function createRuntime(workspaceRoot) {
23
+ const rawConfig = await loadConfig(workspaceRoot);
24
+ const logPath = process.env.DOT_AI_LOG ?? rawConfig.debug?.logPath;
25
+ const logger = logPath ? new JsonFileLogger(logPath) : new NoopLogger();
26
+ const runtime = new DotAiRuntime({
27
+ workspaceRoot,
28
+ logger,
29
+ skipIdentities: true,
30
+ maxSkillLength: 3000,
31
+ maxSkills: 5,
32
+ });
33
+ await runtime.boot();
34
+ return runtime;
35
+ }
36
+ // ── Event handlers ──
37
+ const CLAUDE_SUPPORTED = ADAPTER_CAPABILITIES['claude-code'];
38
+ async function handleSessionStart(event) {
39
+ const workspaceRoot = event.cwd ?? process.cwd();
40
+ const runtime = await createRuntime(workspaceRoot);
41
+ // Log diagnostics
42
+ const diag = runtime.diagnostics;
43
+ process.stderr.write(`[dot-ai] Booted\n`);
44
+ if (diag.extensions.length > 0) {
45
+ process.stderr.write(`[dot-ai] ${diag.extensions.length} extension(s), ${diag.capabilityCount} tool(s)\n`);
46
+ for (const ext of diag.extensions) {
47
+ for (const eventName of Object.keys(ext.handlerCounts)) {
48
+ if (!CLAUDE_SUPPORTED.has(eventName)) {
49
+ process.stderr.write(`[dot-ai] Warning: Extension ${ext.path} uses '${eventName}' (not supported by Claude Code adapter)\n`);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ return runtime;
55
+ }
56
+ async function handlePromptSubmit(event) {
57
+ const workspaceRoot = event.cwd ?? process.cwd();
58
+ const runtime = await createRuntime(workspaceRoot);
59
+ const prompt = (event.prompt ?? event.content ?? '');
60
+ if (!prompt)
61
+ return runtime;
62
+ const { formatted } = await runtime.processPrompt(prompt);
63
+ if (formatted) {
64
+ process.stdout.write(JSON.stringify({ result: formatted }));
65
+ }
66
+ return runtime;
67
+ }
68
+ async function handlePreCompact(event) {
69
+ const workspaceRoot = event.cwd ?? process.cwd();
70
+ const runtime = await createRuntime(workspaceRoot);
71
+ try {
72
+ const summary = (event.summary ?? event.content ?? '');
73
+ if (summary) {
74
+ await runtime.fire('agent_end', {
75
+ response: `[compaction] ${summary.slice(0, 1000)}`,
76
+ });
77
+ }
78
+ }
79
+ catch (err) {
80
+ process.stderr.write(`[dot-ai] pre-compact error: ${err}\n`);
81
+ }
82
+ return runtime;
83
+ }
84
+ async function handleStop(event) {
85
+ const workspaceRoot = event.cwd ?? process.cwd();
86
+ const runtime = await createRuntime(workspaceRoot);
87
+ try {
88
+ const response = (event.response ?? event.content ?? '');
89
+ if (response) {
90
+ await runtime.learn(response);
91
+ }
92
+ }
93
+ catch (err) {
94
+ process.stderr.write(`[dot-ai] stop error: ${err}\n`);
95
+ }
96
+ return runtime;
97
+ }
98
+ async function handlePreToolUse(event) {
99
+ const workspaceRoot = event.cwd ?? process.cwd();
100
+ const toolName = (event.tool_name ?? '');
101
+ const input = (event.tool_input ?? {});
102
+ // Boot runtime to check extension-based tool_call blocking
103
+ const runtime = await createRuntime(workspaceRoot);
104
+ // Fire tool_call to extensions first
105
+ const extensionResult = await runtime.fireToolCall({ tool: toolName, input });
106
+ if (extensionResult?.decision === 'block') {
107
+ process.stdout.write(JSON.stringify({
108
+ decision: 'block',
109
+ reason: extensionResult.reason ?? 'Blocked by extension',
110
+ }));
111
+ return runtime;
112
+ }
113
+ // Existing hardcoded memory file blocking (kept as fallback)
114
+ if (toolName === 'Write' || toolName === 'Edit') {
115
+ const filePath = (input.file_path ?? input.path ?? '');
116
+ if (filePath && /\.ai\/memory\/[^\s]*\.md$/i.test(filePath)) {
117
+ process.stdout.write(JSON.stringify({
118
+ decision: 'block',
119
+ reason: 'Memory is managed by dot-ai SQLite provider. Use the memory_store tool instead.',
120
+ }));
121
+ return runtime;
122
+ }
123
+ }
124
+ if (toolName === 'Bash') {
125
+ const command = (input.command ?? '');
126
+ if (/\.ai\/memory\/[^\s]*\.md/i.test(command) && /(?:>|tee|cp|mv|cat\s*<<|echo\s.*>)/i.test(command)) {
127
+ process.stdout.write(JSON.stringify({
128
+ decision: 'block',
129
+ reason: 'Memory is managed by dot-ai SQLite provider. Use the memory_store tool instead.',
130
+ }));
131
+ }
132
+ }
133
+ return runtime;
134
+ }
135
+ // ── Main dispatcher ──
136
+ async function main() {
137
+ const eventType = process.argv[2] ?? 'prompt-submit';
17
138
  let event;
18
139
  try {
19
- event = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
140
+ event = await readStdin();
20
141
  }
21
142
  catch {
22
143
  process.stderr.write('[dot-ai] Failed to parse stdin JSON\n');
23
144
  return;
24
145
  }
25
- // Find workspace root (try event data, then cwd)
26
- const workspaceRoot = event.cwd ?? process.cwd();
146
+ let runtime;
27
147
  try {
28
- // Run the pipeline
29
- registerDefaults();
30
- const rawConfig = await loadConfig(workspaceRoot);
31
- // Inject workspaceRoot into all provider options
32
- const config = injectRoot(rawConfig, workspaceRoot);
33
- // Setup logger
34
- const logPath = process.env.DOT_AI_LOG ?? rawConfig.debug?.logPath;
35
- const logger = logPath ? new JsonFileLogger(logPath) : new NoopLogger();
36
- const providers = await createProviders(config);
37
- const cache = await boot(providers, logger);
38
- // Extract prompt from the event
39
- const prompt = (event.prompt ?? event.content ?? '');
40
- if (!prompt) {
41
- await logger.flush();
42
- return;
43
- }
44
- // Enrich the prompt
45
- const enriched = await enrich(prompt, providers, cache, logger);
46
- // Load skill content for matched skills
47
- for (const skill of enriched.skills) {
48
- if (!skill.content && skill.name) {
49
- skill.content = await providers.skills.load(skill.name) ?? undefined;
50
- }
51
- }
52
- // Format and output (skip identities — already injected at SessionStart)
53
- const formatted = formatContext(enriched, {
54
- skipIdentities: true,
55
- maxSkillLength: 3000,
56
- maxSkills: 5,
57
- logger,
58
- });
59
- if (formatted) {
60
- process.stdout.write(JSON.stringify({ result: formatted }));
148
+ switch (eventType) {
149
+ case 'session-start':
150
+ runtime = await handleSessionStart(event);
151
+ break;
152
+ case 'prompt-submit':
153
+ runtime = await handlePromptSubmit(event);
154
+ break;
155
+ case 'pre-compact':
156
+ runtime = await handlePreCompact(event);
157
+ break;
158
+ case 'stop':
159
+ runtime = await handleStop(event);
160
+ break;
161
+ case 'pre-tool-use':
162
+ runtime = await handlePreToolUse(event);
163
+ break;
164
+ default: process.stderr.write(`[dot-ai] Unknown event type: ${eventType}\n`);
61
165
  }
62
- await logger.flush();
63
166
  }
64
167
  catch (err) {
65
- // Fail silently — don't block the agent
66
168
  process.stderr.write(`[dot-ai] Error: ${err}\n`);
67
169
  }
170
+ // Flush logger before process exit
171
+ if (runtime)
172
+ await runtime.flush();
68
173
  }
69
174
  main();
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};