Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:02:16 +08:00
commit 6ae6ce0730
49 changed files with 6362 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
project: './tsconfig.eslint.json'
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended'
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
};

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -0,0 +1,57 @@
// plugin/hooks/hooks-app/__tests__/action-handler.test.ts
import { handleAction } from '../src/action-handler';
import { GateResult, GatesConfig } from '../src/types';
const mockConfig: GatesConfig = {
hooks: {},
gates: {
'next-gate': { command: 'echo "next"', on_pass: 'CONTINUE' }
}
};
const mockInput = {
hook_event_name: 'PostToolUse',
cwd: '/test'
};
describe('Action Handler', () => {
test('CONTINUE returns continue=true', async () => {
const result: GateResult = {};
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
expect(action.continue).toBe(true);
expect(action.context).toBeUndefined();
});
test('CONTINUE with context returns context', async () => {
const result: GateResult = { additionalContext: 'test context' };
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
expect(action.continue).toBe(true);
expect(action.context).toBe('test context');
});
test('BLOCK returns continue=false', async () => {
const result: GateResult = { decision: 'block', reason: 'test reason' };
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.blockReason).toBe('test reason');
});
test('BLOCK with no reason uses default', async () => {
const result: GateResult = {};
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.blockReason).toBe('Gate failed');
});
test('STOP returns continue=false with stop message', async () => {
const result: GateResult = { message: 'stop message' };
const action = await handleAction('STOP', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.stopMessage).toBe('stop message');
});
});

View File

@@ -0,0 +1,33 @@
// plugin/hooks/hooks-app/__tests__/builtin-gates.test.ts
import { executeBuiltinGate } from '../src/gate-loader';
import { HookInput } from '../src/types';
import * as path from 'path';
// Set CLAUDE_PLUGIN_ROOT for tests to point to plugin directory
process.env.CLAUDE_PLUGIN_ROOT = path.resolve(__dirname, '../../..');
describe('Built-in Gates', () => {
describe('plugin-path', () => {
test('logs plugin path when available', async () => {
const input: HookInput = {
hook_event_name: 'SessionStart',
cwd: '/test'
};
const result = await executeBuiltinGate('plugin-path', input);
// plugin-path gate should always continue
expect(result.decision).toBeUndefined();
});
test('handles SubagentStop hook', async () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/test',
agent_name: 'test-agent'
};
const result = await executeBuiltinGate('plugin-path', input);
expect(result.decision).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,226 @@
// __tests__/cli.integration.test.ts
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
describe('CLI Integration', () => {
let testDir: string;
beforeEach(async () => {
// Create temp directory for each test
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-test-'));
});
afterEach(async () => {
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
describe('Session Management Mode', () => {
const runCLI = (
args: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
return new Promise((resolve, reject) => {
const proc = spawn('node', ['dist/cli.js', ...args], {
cwd: path.resolve(__dirname, '..')
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
resolve({ stdout, stderr, exitCode: code ?? 0 });
});
proc.on('error', reject);
});
};
test('should set and get active_command', async () => {
// Set
const setResult = await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
expect(setResult.exitCode).toBe(0);
// Get
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim()).toBe('/execute');
});
test('should set and get active_skill', async () => {
// Set
const setResult = await runCLI(['session', 'set', 'active_skill', 'brainstorming', testDir]);
expect(setResult.exitCode).toBe(0);
// Get
const getResult = await runCLI(['session', 'get', 'active_skill', testDir]);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim()).toBe('brainstorming');
});
test('should append to edited_files', async () => {
// Append
const append1 = await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
expect(append1.exitCode).toBe(0);
const append2 = await runCLI(['session', 'append', 'edited_files', 'file2.ts', testDir]);
expect(append2.exitCode).toBe(0);
// Check contains
const contains1 = await runCLI(['session', 'contains', 'edited_files', 'file1.ts', testDir]);
expect(contains1.exitCode).toBe(0);
const contains2 = await runCLI(['session', 'contains', 'edited_files', 'file2.ts', testDir]);
expect(contains2.exitCode).toBe(0);
const notContains = await runCLI([
'session',
'contains',
'edited_files',
'file3.ts',
testDir
]);
expect(notContains.exitCode).toBe(1);
});
test('should append to file_extensions', async () => {
// Append
const append1 = await runCLI(['session', 'append', 'file_extensions', 'ts', testDir]);
expect(append1.exitCode).toBe(0);
const append2 = await runCLI(['session', 'append', 'file_extensions', 'js', testDir]);
expect(append2.exitCode).toBe(0);
// Check contains
const contains1 = await runCLI(['session', 'contains', 'file_extensions', 'ts', testDir]);
expect(contains1.exitCode).toBe(0);
const notContains = await runCLI(['session', 'contains', 'file_extensions', 'py', testDir]);
expect(notContains.exitCode).toBe(1);
});
test('should clear session', async () => {
// Set some data
await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
// Clear
const clearResult = await runCLI(['session', 'clear', testDir]);
expect(clearResult.exitCode).toBe(0);
// Verify cleared
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
expect(getResult.stdout.trim()).toBe('');
});
test('should reject invalid session keys', async () => {
const result = await runCLI(['session', 'get', 'invalid_key', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid session key: invalid_key');
});
test('should reject invalid array keys for append', async () => {
const result = await runCLI(['session', 'append', 'session_id', 'value', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid array key');
});
test('should reject setting non-settable keys', async () => {
const result = await runCLI(['session', 'set', 'session_id', 'value', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Cannot set session_id');
});
test('should show usage for missing arguments', async () => {
const result = await runCLI(['session']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Usage:');
});
test('should show error for unknown session command', async () => {
const result = await runCLI(['session', 'unknown', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Unknown session command');
});
});
describe('Hook Dispatch Mode', () => {
test('should handle hook dispatch with valid JSON input', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
const input = JSON.stringify({
hook_event_name: 'PostToolUse',
cwd: testDir,
tool_name: 'Edit',
tool_input: {}
});
let stdout = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.on('close', (code) => {
expect(code).toBe(0);
// Should produce empty output or valid JSON
if (stdout.trim()) {
expect(() => JSON.parse(stdout)).not.toThrow();
}
done();
});
proc.stdin.write(input);
proc.stdin.end();
});
test('should handle graceful exit on missing required fields', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
const input = JSON.stringify({
// Missing hook_event_name and cwd
tool_name: 'Edit'
});
proc.on('close', (code) => {
expect(code).toBe(0); // Graceful exit
done();
});
proc.stdin.write(input);
proc.stdin.end();
});
test('should handle invalid JSON input', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
let stderr = '';
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
expect(code).toBe(1);
expect(stderr).toContain('Invalid JSON input');
done();
});
proc.stdin.write('not valid json');
proc.stdin.end();
});
});
});

View File

@@ -0,0 +1,250 @@
// plugin/hooks/hooks-app/__tests__/config.test.ts
import { loadConfig, resolvePluginPath } from '../src/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Config Loading', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('returns plugin defaults when no project config exists', async () => {
// Config loader now returns plugin defaults when no project config exists
// This provides fallback behavior without requiring every project to have gates.json
const config = await loadConfig(testDir);
expect(config).not.toBeNull();
// Verify it's actually plugin defaults by checking for expected structure
expect(config?.hooks).toBeDefined();
expect(config?.gates).toBeDefined();
});
test('loads .claude/gates.json with highest priority', async () => {
const claudeDir = path.join(testDir, '.claude');
await fs.mkdir(claudeDir);
const config1 = { hooks: {}, gates: { test: { command: 'claude-config' } } };
const config2 = { hooks: {}, gates: { test: { command: 'root-config' } } };
await fs.writeFile(path.join(claudeDir, 'gates.json'), JSON.stringify(config1));
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config2));
const config = await loadConfig(testDir);
expect(config?.gates.test.command).toBe('claude-config');
});
test('loads gates.json from root when .claude does not exist', async () => {
const config1 = { hooks: {}, gates: { test: { command: 'root-config' } } };
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config1));
const config = await loadConfig(testDir);
expect(config?.gates.test.command).toBe('root-config');
});
test('parses valid JSON config', async () => {
const configObj = {
hooks: {
PostToolUse: {
enabled_tools: ['Edit', 'Write'],
gates: ['format', 'test']
}
},
gates: {
format: { command: 'npm run format', on_pass: 'CONTINUE' },
test: { command: 'npm test', on_pass: 'CONTINUE' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
const config = await loadConfig(testDir);
expect(config?.hooks.PostToolUse.enabled_tools).toEqual(['Edit', 'Write']);
expect(config?.gates.format.command).toBe('npm run format');
});
test('rejects unknown hook event', async () => {
const configObj = {
hooks: {
UnknownEvent: { gates: [] }
},
gates: {}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow('Unknown hook event');
});
test('rejects undefined gate reference', async () => {
const configObj = {
hooks: {
PostToolUse: { gates: ['nonexistent'] }
},
gates: {}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow('references undefined gate');
});
test('rejects invalid action', async () => {
const configObj = {
hooks: {
PostToolUse: { gates: ['test'] }
},
gates: {
test: { command: 'echo test', on_pass: 'INVALID' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
'is not CONTINUE/BLOCK/STOP or valid gate name'
);
});
});
describe('Plugin Path Resolution', () => {
test('resolves sibling plugin using CLAUDE_PLUGIN_ROOT', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
const result = resolvePluginPath('cipherpowers');
expect(result).toBe('/home/user/.claude/plugins/cipherpowers');
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('throws when CLAUDE_PLUGIN_ROOT not set', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
delete process.env.CLAUDE_PLUGIN_ROOT;
try {
expect(() => resolvePluginPath('cipherpowers')).toThrow(
'Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set'
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('rejects plugin names with path separators', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
expect(() => resolvePluginPath('../etc')).toThrow(
"Invalid plugin name: '../etc' (must not contain path separators)"
);
expect(() => resolvePluginPath('foo/bar')).toThrow(
"Invalid plugin name: 'foo/bar' (must not contain path separators)"
);
expect(() => resolvePluginPath('foo\\bar')).toThrow(
"Invalid plugin name: 'foo\\bar' (must not contain path separators)"
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('rejects plugin names with parent directory references', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
expect(() => resolvePluginPath('..')).toThrow(
"Invalid plugin name: '..' (must not contain path separators)"
);
expect(() => resolvePluginPath('..foo')).toThrow(
"Invalid plugin name: '..foo' (must not contain path separators)"
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
});
describe('Gate Config Validation', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('rejects gate with plugin but no gate name', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: { plugin: 'cipherpowers' } // Missing gate field
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' has 'plugin' but missing 'gate' field"
);
});
test('rejects gate with gate name but no plugin', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: { gate: 'plan-compliance' } // Missing plugin field
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' has 'gate' but missing 'plugin' field"
);
});
test('rejects gate with both command and plugin', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: {
plugin: 'cipherpowers',
gate: 'plan-compliance',
command: 'npm run lint' // Conflicting
}
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' cannot have both 'command' and 'plugin/gate'"
);
});
test('accepts valid plugin gate reference', async () => {
const configObj = {
hooks: {},
gates: {
test: { plugin: 'cipherpowers', gate: 'plan-compliance' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
// Should not throw validation error for structure
// (May fail later when trying to resolve plugin, which is acceptable)
const config = await loadConfig(testDir);
expect(config).not.toBeNull();
expect(config?.gates.test.plugin).toBe('cipherpowers');
expect(config?.gates.test.gate).toBe('plan-compliance');
});
});

View File

@@ -0,0 +1,69 @@
// plugin/hooks/hooks-app/__tests__/context.test.ts
import { discoverContextFile } from '../src/context';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Context Injection', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('returns null when no context file exists', async () => {
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBeNull();
});
test('discovers flat context file', async () => {
const contextDir = path.join(testDir, '.claude', 'context');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
});
test('discovers slash-command subdirectory', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
});
test('discovers nested slash-command directory', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command', 'test-command');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'start.md'));
});
test('discovers skill context', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'skill');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-skill-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-skill', 'start');
expect(result).toBe(path.join(contextDir, 'test-skill-start.md'));
});
test('follows priority order - flat wins', async () => {
const contextBase = path.join(testDir, '.claude', 'context');
await fs.mkdir(path.join(contextBase, 'slash-command'), { recursive: true });
await fs.writeFile(path.join(contextBase, 'test-command-start.md'), 'flat');
await fs.writeFile(path.join(contextBase, 'slash-command', 'test-command-start.md'), 'subdir');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextBase, 'test-command-start.md'));
});
});

View File

@@ -0,0 +1,263 @@
// plugin/hooks/hooks-app/__tests__/dispatcher.test.ts
import { shouldProcessHook, dispatch, gateMatchesKeywords } from '../src/dispatcher';
import { HookInput, HookConfig, GateConfig } from '../src/types';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Dispatcher - Event Filtering', () => {
test('PostToolUse with enabled tool returns true', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test',
tool_name: 'Edit'
};
const hookConfig: HookConfig = {
enabled_tools: ['Edit', 'Write']
};
expect(shouldProcessHook(input, hookConfig)).toBe(true);
});
test('PostToolUse with disabled tool returns false', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test',
tool_name: 'Read'
};
const hookConfig: HookConfig = {
enabled_tools: ['Edit', 'Write']
};
expect(shouldProcessHook(input, hookConfig)).toBe(false);
});
test('SubagentStop with enabled agent returns true', () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/test',
agent_name: 'test-namespace:test-agent'
};
const hookConfig: HookConfig = {
enabled_agents: ['test-namespace:test-agent']
};
expect(shouldProcessHook(input, hookConfig)).toBe(true);
});
test('SubagentStop with disabled agent returns false', () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/test',
agent_name: 'other-agent'
};
const hookConfig: HookConfig = {
enabled_agents: ['test-namespace:test-agent']
};
expect(shouldProcessHook(input, hookConfig)).toBe(false);
});
test('SubagentStop checks subagent_name if agent_name missing', () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/test',
subagent_name: 'test-namespace:test-agent'
};
const hookConfig: HookConfig = {
enabled_agents: ['test-namespace:test-agent']
};
expect(shouldProcessHook(input, hookConfig)).toBe(true);
});
test('UserPromptSubmit always returns true', () => {
const input: HookInput = {
hook_event_name: 'UserPromptSubmit',
cwd: '/test'
};
const hookConfig: HookConfig = {};
expect(shouldProcessHook(input, hookConfig)).toBe(true);
});
test('No filtering config returns true', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test',
tool_name: 'Edit'
};
const hookConfig: HookConfig = {};
expect(shouldProcessHook(input, hookConfig)).toBe(true);
});
});
describe('Dispatcher - Gate Chaining', () => {
let testDir: string;
beforeEach(async () => {
// Create temporary directory for test config
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gates-test-'));
});
afterEach(async () => {
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
test('gate chaining works - gate-a chains to gate-b on pass', async () => {
// Create gates.json with chaining config
const gatesConfig = {
hooks: {
PostToolUse: {
gates: ['gate-a']
}
},
gates: {
'gate-a': {
command: 'echo "gate-a passed"',
on_pass: 'gate-b' // Chain to gate-b on pass
},
'gate-b': {
command: 'echo "gate-b passed"',
on_pass: 'CONTINUE'
}
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: testDir,
tool_name: 'Edit'
};
const result = await dispatch(input);
// Should contain output from both gates
expect(result.context).toContain('gate-a passed');
expect(result.context).toContain('gate-b passed');
expect(result.blockReason).toBeUndefined();
});
test('circular chain prevention - exceeds max gate depth', async () => {
// Create gates.json with circular chain
const gatesConfig = {
hooks: {
PostToolUse: {
gates: ['gate-a']
}
},
gates: {
'gate-a': {
command: 'echo "gate-a"',
on_pass: 'gate-b'
},
'gate-b': {
command: 'echo "gate-b"',
on_pass: 'gate-a' // Circular chain back to gate-a
}
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: testDir,
tool_name: 'Edit'
};
const result = await dispatch(input);
// Should hit circuit breaker
expect(result.blockReason).toContain('Exceeded max gate chain depth');
expect(result.blockReason).toContain('circular');
});
});
describe('Keyword Matching', () => {
test('no keywords - gate always runs', () => {
const gateConfig: GateConfig = {
command: 'npm test'
};
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
expect(gateMatchesKeywords(gateConfig, '')).toBe(true);
});
test('empty keywords array - gate always runs', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: []
};
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
});
test('no user message with keywords - gate does not run', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: ['test', 'testing']
};
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(false);
expect(gateMatchesKeywords(gateConfig, '')).toBe(false);
});
test('keyword match - case insensitive', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: ['test']
};
expect(gateMatchesKeywords(gateConfig, 'run the TEST')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'RUN THE Test')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'test this')).toBe(true);
});
test('multiple keywords - any matches', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: ['test', 'testing', 'spec', 'verify']
};
expect(gateMatchesKeywords(gateConfig, 'run the tests')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'verify this works')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'check the spec')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'we are testing')).toBe(true);
});
test('no keyword match - gate does not run', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: ['test', 'testing']
};
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(false);
expect(gateMatchesKeywords(gateConfig, 'run the linter')).toBe(false);
});
test('substring matching - partial word matches', () => {
const gateConfig: GateConfig = {
command: 'npm test',
keywords: ['test']
};
// Intentional substring matching (not word-boundary)
expect(gateMatchesKeywords(gateConfig, 'latest version')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'contest results')).toBe(true);
expect(gateMatchesKeywords(gateConfig, 'testing')).toBe(true);
});
});

View File

@@ -0,0 +1,178 @@
// plugin/hooks/hooks-app/__tests__/gate-loader.test.ts
import { executeShellCommand, executeGate, loadPluginGate } from '../src/gate-loader';
import { GateConfig, HookInput } from '../src/types';
import * as os from 'os';
import * as fs from 'fs/promises';
import * as path from 'path';
describe('Gate Loader - Shell Commands', () => {
test('executes shell command and returns exit code', async () => {
const result = await executeShellCommand('echo "test"', process.cwd());
expect(result.exitCode).toBe(0);
expect(result.output).toContain('test');
});
test('captures non-zero exit code', async () => {
const result = await executeShellCommand('exit 1', process.cwd());
expect(result.exitCode).toBe(1);
});
test('captures stdout', async () => {
const result = await executeShellCommand('echo "hello world"', process.cwd());
expect(result.output).toContain('hello world');
});
test('captures stderr', async () => {
const result = await executeShellCommand('echo "error" >&2', process.cwd());
expect(result.output).toContain('error');
});
test('executes in specified directory', async () => {
const tmpDir = os.tmpdir();
const result = await executeShellCommand('pwd', tmpDir);
// macOS may prepend /private to paths
expect(result.output.trim()).toMatch(new RegExp(tmpDir.replace('/var/', '(/private)?/var/')));
});
test('timeout returns exit code 124 and timeout message', async () => {
const result = await executeShellCommand('sleep 1', process.cwd(), 100);
expect(result.exitCode).toBe(124);
expect(result.output).toContain('timed out');
});
});
describe('Gate Loader - executeGate', () => {
const mockInput: HookInput = {
hook_event_name: 'PostToolUse',
cwd: process.cwd()
};
test('shell command gate with exit 0 returns passed=true', async () => {
const gateConfig: GateConfig = {
command: 'echo "success"'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(true);
expect(result.result.additionalContext).toContain('success');
});
test('shell command gate with exit 1 returns passed=false', async () => {
const gateConfig: GateConfig = {
command: 'exit 1'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(false);
});
test('built-in gate throws error when gate not found', async () => {
const gateConfig: GateConfig = {
// No command = built-in gate
};
await expect(executeGate('nonexistent-gate', gateConfig, mockInput)).rejects.toThrow(
'Failed to load built-in gate nonexistent-gate'
);
});
});
describe('Plugin Gate Loading', () => {
let mockPluginDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
// Create mock plugin directory structure
mockPluginDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
const cipherpowersDir = path.join(mockPluginDir, 'cipherpowers', 'hooks');
await fs.mkdir(cipherpowersDir, { recursive: true });
// Create mock gates.json for cipherpowers
const gatesConfig = {
hooks: {},
gates: {
'plan-compliance': {
command: 'node dist/gates/plan-compliance.js',
on_fail: 'BLOCK'
}
}
};
await fs.writeFile(
path.join(cipherpowersDir, 'gates.json'),
JSON.stringify(gatesConfig)
);
// Set CLAUDE_PLUGIN_ROOT to point to turboshovel sibling
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginDir, 'turboshovel');
});
afterEach(async () => {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
await fs.rm(mockPluginDir, { recursive: true, force: true });
});
test('loads gate config from plugin', async () => {
const result = await loadPluginGate('cipherpowers', 'plan-compliance');
expect(result.gateConfig.command).toBe('node dist/gates/plan-compliance.js');
expect(result.gateConfig.on_fail).toBe('BLOCK');
expect(result.pluginRoot).toBe(path.join(mockPluginDir, 'cipherpowers'));
});
test('throws when plugin gates.json not found', async () => {
await expect(loadPluginGate('nonexistent', 'some-gate')).rejects.toThrow(
"Cannot find gates.json for plugin 'nonexistent'"
);
});
test('throws when gate not found in plugin', async () => {
await expect(loadPluginGate('cipherpowers', 'nonexistent-gate')).rejects.toThrow(
"Gate 'nonexistent-gate' not found in plugin 'cipherpowers'"
);
});
test('validates loaded plugin config structure', async () => {
// Create plugin with malformed gates.json
const malformedDir = path.join(mockPluginDir, 'malformed', 'hooks');
await fs.mkdir(malformedDir, { recursive: true });
await fs.writeFile(
path.join(malformedDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'bad-gate': {
// Missing required fields (no command, plugin, or gate)
}
}
})
);
// This should succeed loading but the gate config is invalid
// Validation happens when the gate is used, not when loading
const result = await loadPluginGate('malformed', 'bad-gate');
expect(result.gateConfig).toBeDefined();
});
test('executeGate handles plugin gate reference', async () => {
const gateConfig: GateConfig = {
plugin: 'cipherpowers',
gate: 'plan-compliance'
};
const mockInput: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/some/project'
};
// The command from cipherpowers will be executed in cipherpowers plugin dir
// For this test, the mock plugin has 'node dist/gates/plan-compliance.js'
// which won't exist, so it will fail - but we can verify the flow
const result = await executeGate('my-gate', gateConfig, mockInput);
// Command execution will fail (file doesn't exist) but flow is correct
expect(result.passed).toBe(false);
});
});

View File

@@ -0,0 +1,164 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { join, dirname } from 'path';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
const execAsync = promisify(exec);
describe('Integration Tests', () => {
let testDir: string;
let cliPath: string;
beforeEach(async () => {
testDir = join(tmpdir(), `integration-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
cliPath = join(__dirname, '../dist/cli.js');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('Session Management', () => {
test('set and get command', async () => {
await execAsync(`node ${cliPath} session set active_command /execute ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('/execute');
});
test('append and check contains', async () => {
await execAsync(`node ${cliPath} session append file_extensions ts ${testDir}`);
const result = await execAsync(
`node ${cliPath} session contains file_extensions ts ${testDir}`
)
.then(() => true)
.catch(() => false);
expect(result).toBe(true);
});
test('clear removes state', async () => {
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
await execAsync(`node ${cliPath} session clear ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('');
});
});
describe('Hook Dispatch with Session Tracking', () => {
test('PostToolUse updates session', async () => {
const hookInput = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
file_path: 'main.ts',
cwd: testDir
});
await execAsync(`echo '${hookInput}' | node ${cliPath}`);
const { stdout: files } = await execAsync(
`node ${cliPath} session get edited_files ${testDir}`
);
expect(files).toContain('main.ts');
const containsTs = await execAsync(
`node ${cliPath} session contains file_extensions ts ${testDir}`
)
.then(() => true)
.catch(() => false);
expect(containsTs).toBe(true);
});
test('SlashCommandStart/End updates session', async () => {
// Start command
const startInput = JSON.stringify({
hook_event_name: 'SlashCommandStart',
command: '/execute',
cwd: testDir
});
await execAsync(`echo '${startInput}' | node ${cliPath}`);
const { stdout: activeCmd } = await execAsync(
`node ${cliPath} session get active_command ${testDir}`
);
expect(activeCmd.trim()).toBe('/execute');
// End command
const endInput = JSON.stringify({
hook_event_name: 'SlashCommandEnd',
command: '/execute',
cwd: testDir
});
await execAsync(`echo '${endInput}' | node ${cliPath}`);
const { stdout: cleared } = await execAsync(
`node ${cliPath} session get active_command ${testDir}`
);
expect(cleared.trim()).toBe('');
});
test('SkillStart/End updates session', async () => {
// Start skill
const startInput = JSON.stringify({
hook_event_name: 'SkillStart',
skill: 'executing-plans',
cwd: testDir
});
await execAsync(`echo '${startInput}' | node ${cliPath}`);
const { stdout: activeSkill } = await execAsync(
`node ${cliPath} session get active_skill ${testDir}`
);
expect(activeSkill.trim()).toBe('executing-plans');
// End skill
const endInput = JSON.stringify({
hook_event_name: 'SkillEnd',
skill: 'executing-plans',
cwd: testDir
});
await execAsync(`echo '${endInput}' | node ${cliPath}`);
const { stdout: cleared } = await execAsync(
`node ${cliPath} session get active_skill ${testDir}`
);
expect(cleared.trim()).toBe('');
});
});
describe('Error Handling', () => {
test('handles corrupted state file gracefully', async () => {
const stateFile = join(testDir, '.claude', 'session', 'state.json');
await fs.mkdir(dirname(stateFile), { recursive: true });
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
// Should reinitialize and work
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('/plan');
});
test('rejects invalid session keys', async () => {
try {
await execAsync(`node ${cliPath} session get invalid_key ${testDir}`);
fail('Should have thrown error');
} catch (error) {
const err = error as { stderr?: string };
expect(err.stderr).toContain('Invalid session key');
}
});
test('rejects invalid array keys for append', async () => {
try {
await execAsync(`node ${cliPath} session append invalid_key value ${testDir}`);
fail('Should have thrown error');
} catch (error) {
const err = error as { stderr?: string };
expect(err.stderr).toContain('Invalid array key');
}
});
});
});

View File

@@ -0,0 +1,239 @@
// plugin/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
import { dispatch } from '../src/dispatcher';
import { HookInput } from '../src/types';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Plugin Gate Composition Integration', () => {
let mockPluginsDir: string;
let projectDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
// Create mock plugins directory with two plugins
mockPluginsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
// Create mock cipherpowers plugin
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
await fs.mkdir(cipherpowersHooksDir, { recursive: true });
await fs.writeFile(
path.join(cipherpowersHooksDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'plan-compliance': {
command: 'echo "plan-compliance check passed"',
on_fail: 'BLOCK'
}
}
})
);
// Create mock turboshovel plugin (current plugin)
const turboshovelHooksDir = path.join(mockPluginsDir, 'turboshovel', 'hooks');
await fs.mkdir(turboshovelHooksDir, { recursive: true });
await fs.writeFile(
path.join(turboshovelHooksDir, 'gates.json'),
JSON.stringify({ hooks: {}, gates: {} })
);
// Create test project directory
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-project-'));
const claudeDir = path.join(projectDir, '.claude');
await fs.mkdir(claudeDir);
// Project config references cipherpowers gate
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['plan-compliance', 'check']
}
},
gates: {
'plan-compliance': {
plugin: 'cipherpowers',
gate: 'plan-compliance'
},
'check': {
command: 'echo "project check passed"'
}
}
})
);
// Set CLAUDE_PLUGIN_ROOT
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginsDir, 'turboshovel');
});
afterEach(async () => {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
await fs.rm(mockPluginsDir, { recursive: true, force: true });
await fs.rm(projectDir, { recursive: true, force: true });
});
test('executes plugin gate followed by project gate', async () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
const result = await dispatch(input);
// Both gates should pass (no blockReason or stopMessage)
expect(result.blockReason).toBeUndefined();
expect(result.stopMessage).toBeUndefined();
// Should have output from both gates
expect(result.context).toContain('plan-compliance check passed');
expect(result.context).toContain('project check passed');
});
test('plugin gate BLOCK stops execution', async () => {
// Update cipherpowers gate to fail
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
await fs.writeFile(
path.join(cipherpowersHooksDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'plan-compliance': {
command: 'exit 1',
on_fail: 'BLOCK'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
const result = await dispatch(input);
// Should be blocked (blockReason will be set)
expect(result.blockReason).toBeDefined();
});
test('prevents circular gate references', async () => {
// Create circular reference: pluginA -> pluginB -> pluginA
const pluginADir = path.join(mockPluginsDir, 'pluginA', 'hooks');
const pluginBDir = path.join(mockPluginsDir, 'pluginB', 'hooks');
await fs.mkdir(pluginADir, { recursive: true });
await fs.mkdir(pluginBDir, { recursive: true });
// PluginA has gate that references pluginB
await fs.writeFile(
path.join(pluginADir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gateA': {
plugin: 'pluginB',
gate: 'gateB'
}
}
})
);
// PluginB has gate that references pluginA (circular)
await fs.writeFile(
path.join(pluginBDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gateB': {
plugin: 'pluginA',
gate: 'gateA'
}
}
})
);
// Project config references pluginA gate
const claudeDir = path.join(projectDir, '.claude');
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['test-circular']
}
},
gates: {
'test-circular': {
plugin: 'pluginA',
gate: 'gateA'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
// Should error or handle gracefully (not infinite loop)
// Implementation decision: error on circular reference
await expect(dispatch(input)).rejects.toThrow(/circular|depth|recursion/i);
});
test('handles plugin self-reference', async () => {
// Plugin references its own gate
const selfRefDir = path.join(mockPluginsDir, 'selfref', 'hooks');
await fs.mkdir(selfRefDir, { recursive: true });
await fs.writeFile(
path.join(selfRefDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gate1': {
command: 'echo "gate1"'
},
'gate2': {
plugin: 'selfref',
gate: 'gate1'
}
}
})
);
// Project references the self-referencing gate
const claudeDir = path.join(projectDir, '.claude');
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['test-self']
}
},
gates: {
'test-self': {
plugin: 'selfref',
gate: 'gate2'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
// Should work - self-reference to a different gate is valid
const result = await dispatch(input);
expect(result.blockReason).toBeUndefined();
expect(result.context).toContain('gate1');
});
});

View File

@@ -0,0 +1,198 @@
import { Session } from '../src/session';
import { promises as fs } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
describe('Session', () => {
let testDir: string;
beforeEach(async () => {
testDir = join(tmpdir(), `session-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('constructor', () => {
test('sets state file path', () => {
const session = new Session(testDir);
expect(session['stateFile']).toBe(join(testDir, '.claude', 'session', 'state.json'));
});
});
describe('get/set', () => {
test('set and get scalar value', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const value = await session.get('active_command');
expect(value).toBe('/execute');
});
test('get returns null for unset values', async () => {
const session = new Session(testDir);
const value = await session.get('active_skill');
expect(value).toBeNull();
});
test('set multiple values independently', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
await session.set('active_skill', 'executing-plans');
expect(await session.get('active_command')).toBe('/execute');
expect(await session.get('active_skill')).toBe('executing-plans');
});
});
describe('append/contains', () => {
test('append adds value to array', async () => {
const session = new Session(testDir);
await session.append('edited_files', 'main.ts');
await session.append('edited_files', 'lib.ts');
const files = await session.get('edited_files');
expect(files).toEqual(['main.ts', 'lib.ts']);
});
test('append deduplicates values', async () => {
const session = new Session(testDir);
await session.append('edited_files', 'main.ts');
await session.append('edited_files', 'lib.ts');
await session.append('edited_files', 'main.ts'); // Duplicate
const files = await session.get('edited_files');
expect(files).toEqual(['main.ts', 'lib.ts']);
});
test('contains returns true for existing value', async () => {
const session = new Session(testDir);
await session.append('file_extensions', 'ts');
await session.append('file_extensions', 'js');
expect(await session.contains('file_extensions', 'ts')).toBe(true);
expect(await session.contains('file_extensions', 'js')).toBe(true);
});
test('contains returns false for missing value', async () => {
const session = new Session(testDir);
await session.append('file_extensions', 'ts');
expect(await session.contains('file_extensions', 'rs')).toBe(false);
});
});
describe('clear', () => {
test('removes state file', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const stateFile = join(testDir, '.claude', 'session', 'state.json');
const exists = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
await session.clear();
const existsAfter = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(existsAfter).toBe(false);
});
test('is safe when file does not exist', async () => {
const session = new Session(testDir);
await expect(session.clear()).resolves.not.toThrow();
});
});
describe('persistence', () => {
test('state persists across Session instances', async () => {
const session1 = new Session(testDir);
await session1.set('active_command', '/plan');
await session1.append('edited_files', 'main.ts');
const session2 = new Session(testDir);
expect(await session2.get('active_command')).toBe('/plan');
expect(await session2.get('edited_files')).toEqual(['main.ts']);
});
});
describe('atomic writes', () => {
test('uses atomic rename', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const stateFile = join(testDir, '.claude', 'session', 'state.json');
const tempFile = stateFile + '.tmp';
// Temp file should not exist after save completes
const tempExists = await fs
.access(tempFile)
.then(() => true)
.catch(() => false);
expect(tempExists).toBe(false);
// State file should exist
const stateExists = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(stateExists).toBe(true);
});
});
describe('error scenarios', () => {
test('handles corrupted JSON gracefully', async () => {
const session = new Session(testDir);
const stateFile = join(testDir, '.claude', 'session', 'state.json');
// Create directory and write corrupted JSON
await fs.mkdir(dirname(stateFile), { recursive: true });
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
// Should reinitialize state on corruption
const value = await session.get('active_command');
expect(value).toBeNull();
});
test('handles cross-process persistence', async () => {
// Simulate separate process invocations
const session1 = new Session(testDir);
await session1.set('active_command', '/execute');
await session1.append('edited_files', 'main.ts');
// Create new session instance (simulates new process)
const session2 = new Session(testDir);
expect(await session2.get('active_command')).toBe('/execute');
expect(await session2.get('edited_files')).toEqual(['main.ts']);
});
test('handles concurrent writes via atomic rename', async () => {
const session = new Session(testDir);
// Rapid concurrent writes (atomic rename prevents corruption)
// Note: Some writes may fail due to temp file conflicts, but state file
// should never be corrupted (that's what atomic rename protects against)
const results = await Promise.allSettled([
session.append('edited_files', 'file1.ts'),
session.append('edited_files', 'file2.ts'),
session.append('edited_files', 'file3.ts')
]);
// At least one operation should succeed
const successCount = results.filter((r) => r.status === 'fulfilled').length;
expect(successCount).toBeGreaterThan(0);
// State file should be valid (not corrupted)
const files = await session.get('edited_files');
expect(Array.isArray(files)).toBe(true);
expect(files.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,63 @@
// plugin/hooks/hooks-app/__tests__/types.test.ts
import { HookInput, GateResult, GateConfig } from '../src/types';
describe('Types', () => {
test('HookInput has required fields', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test/path'
};
expect(input.hook_event_name).toBe('PostToolUse');
expect(input.cwd).toBe('/test/path');
});
test('HookInput accepts optional PostToolUse fields', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test/path',
tool_name: 'Edit',
file_path: '/test/file.ts'
};
expect(input.tool_name).toBe('Edit');
expect(input.file_path).toBe('/test/file.ts');
});
test('GateResult can be empty object', () => {
const result: GateResult = {};
expect(result).toBeDefined();
});
test('GateResult can have additionalContext', () => {
const result: GateResult = {
additionalContext: 'Test context'
};
expect(result.additionalContext).toBe('Test context');
});
test('GateResult can have block decision', () => {
const result: GateResult = {
decision: 'block',
reason: 'Test reason'
};
expect(result.decision).toBe('block');
expect(result.reason).toBe('Test reason');
});
});
describe('GateConfig Type', () => {
test('accepts plugin gate reference', () => {
const config: GateConfig = {
plugin: 'cipherpowers',
gate: 'plan-compliance'
};
expect(config.plugin).toBe('cipherpowers');
expect(config.gate).toBe('plan-compliance');
});
test('accepts local command gate', () => {
const config: GateConfig = {
command: 'npm run lint'
};
expect(config.command).toBe('npm run lint');
});
});

View File

@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/__tests__'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts'],
moduleFileExtensions: ['ts', 'js', 'json']
};

View File

@@ -0,0 +1,31 @@
{
"name": "@turboshovel/hooks-app",
"version": "1.0.0",
"description": "TypeScript hooks dispatcher for Turboshovel",
"main": "dist/cli.js",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "jest",
"lint": "eslint src/**/*.ts __tests__/**/*.ts",
"lint:fix": "eslint src/**/*.ts __tests__/**/*.ts --fix",
"format": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
},
"dependencies": {
"js-yaml": "^4.1.1"
}
}

View File

@@ -0,0 +1,45 @@
// plugin/hooks/hooks-app/src/action-handler.ts
import { GateResult, GatesConfig, HookInput } from './types';
export interface ActionResult {
continue: boolean;
context?: string;
blockReason?: string;
stopMessage?: string;
chainedGate?: string;
}
export async function handleAction(
action: string,
gateResult: GateResult,
_config: GatesConfig,
_input: HookInput
): Promise<ActionResult> {
switch (action) {
case 'CONTINUE':
return {
continue: true,
context: gateResult.additionalContext
};
case 'BLOCK':
return {
continue: false,
blockReason: gateResult.reason || 'Gate failed'
};
case 'STOP':
return {
continue: false,
stopMessage: gateResult.message || 'Gate stopped execution'
};
default:
// Gate chaining - action is another gate name
return {
continue: true,
context: gateResult.additionalContext,
chainedGate: action
};
}
}

268
hooks/hooks-app/src/cli.ts Normal file
View File

@@ -0,0 +1,268 @@
// plugin/hooks/hooks-app/src/cli.ts
import { HookInput, SessionState, SessionStateArrayKey } from './types';
import { dispatch } from './dispatcher';
import { Session } from './session';
import { logger } from './logger';
interface OutputMessage {
additionalContext?: string;
decision?: string;
reason?: string;
continue?: boolean;
message?: string;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Check if first arg is "session" - session management mode
if (args.length > 0 && args[0] === 'session') {
await handleSessionCommand(args.slice(1));
return;
}
// Check if first arg is "log-path" - return log file path for mise tasks
if (args.length > 0 && args[0] === 'log-path') {
console.log(logger.getLogFilePath());
return;
}
// Check if first arg is "log-dir" - return log directory for mise tasks
if (args.length > 0 && args[0] === 'log-dir') {
console.log(logger.getLogDir());
return;
}
// Otherwise, hook dispatch mode (existing behavior)
await handleHookDispatch();
}
/**
* Type guard for SessionState keys
*/
function isSessionStateKey(key: string): key is keyof SessionState {
const validKeys = [
'session_id',
'started_at',
'active_command',
'active_skill',
'edited_files',
'file_extensions',
'metadata'
] as const;
return (validKeys as readonly string[]).includes(key);
}
/**
* Type guard for array keys
*/
function isArrayKey(key: string): key is SessionStateArrayKey {
return key === 'edited_files' || key === 'file_extensions';
}
/**
* Handle session management commands with proper type safety
*/
async function handleSessionCommand(args: string[]): Promise<void> {
if (args.length < 1) {
console.error('Usage: hooks-app session [get|set|append|contains|clear] ...');
process.exit(1);
}
const [command, ...params] = args;
const cwd = params[params.length - 1] || '.';
const session = new Session(cwd);
try {
switch (command) {
case 'get': {
if (params.length < 2) {
console.error('Usage: hooks-app session get <key> [cwd]');
process.exit(1);
}
const [key] = params;
if (!isSessionStateKey(key)) {
console.error(`Invalid session key: ${key}`);
process.exit(1);
}
const value = await session.get(key);
console.log(value ?? '');
break;
}
case 'set': {
if (params.length < 3) {
console.error('Usage: hooks-app session set <key> <value> [cwd]');
process.exit(1);
}
const [key, value] = params;
if (!isSessionStateKey(key)) {
console.error(`Invalid session key: ${key}`);
process.exit(1);
}
// Type-safe set with runtime validation
if (key === 'active_command' || key === 'active_skill') {
await session.set(key, value === 'null' ? null : value);
} else if (key === 'metadata') {
await session.set(key, JSON.parse(value));
} else {
console.error(`Cannot set ${key} via CLI (use get, append, or contains)`);
process.exit(1);
}
break;
}
case 'append': {
if (params.length < 3) {
console.error('Usage: hooks-app session append <key> <value> [cwd]');
process.exit(1);
}
const [key, value] = params;
if (!isArrayKey(key)) {
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
process.exit(1);
}
await session.append(key, value);
break;
}
case 'contains': {
if (params.length < 3) {
console.error('Usage: hooks-app session contains <key> <value> [cwd]');
process.exit(1);
}
const [key, value] = params;
if (!isArrayKey(key)) {
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
process.exit(1);
}
const result = await session.contains(key, value);
process.exit(result ? 0 : 1);
break;
}
case 'clear': {
await session.clear();
break;
}
default:
console.error(`Unknown session command: ${command}`);
process.exit(1);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await logger.error('Session command failed', { command, error: errorMessage });
console.error(`Session error: ${errorMessage}`);
process.exit(1);
}
}
/**
* Handle hook dispatch (existing behavior)
*/
async function handleHookDispatch(): Promise<void> {
try {
// Read stdin
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const inputStr = Buffer.concat(chunks).toString('utf-8');
// ALWAYS log hook invocation (unconditional - for debugging)
await logger.always('HOOK_INVOKED', {
input_length: inputStr.length,
input_preview: inputStr.substring(0, 500)
});
// Log raw input at CLI entry point
await logger.debug('CLI received hook input', {
input_length: inputStr.length,
input_preview: inputStr.substring(0, 200)
});
// Parse input
let input: HookInput;
try {
input = JSON.parse(inputStr);
} catch (error) {
await logger.error('CLI failed to parse JSON input', {
input_preview: inputStr.substring(0, 200),
error: error instanceof Error ? error.message : String(error)
});
console.error(
JSON.stringify({
continue: false,
message: 'Invalid JSON input'
})
);
process.exit(1);
}
// Log parsed hook event
await logger.info('CLI dispatching hook', {
event: input.hook_event_name,
cwd: input.cwd,
tool: input.tool_name,
agent: input.agent_name,
command: input.command,
skill: input.skill
});
// Validate required fields
if (!input.hook_event_name || !input.cwd) {
await logger.warn('CLI missing required fields, exiting', {
has_event: !!input.hook_event_name,
has_cwd: !!input.cwd
});
return;
}
// Dispatch
const result = await dispatch(input);
// Build output
const output: OutputMessage = {};
if (result.context) {
output.additionalContext = result.context;
}
if (result.blockReason) {
output.decision = 'block';
output.reason = result.blockReason;
}
if (result.stopMessage) {
output.continue = false;
output.message = result.stopMessage;
}
// Log result
await logger.info('CLI hook completed', {
event: input.hook_event_name,
has_context: !!result.context,
has_block: !!result.blockReason,
has_stop: !!result.stopMessage,
output_keys: Object.keys(output)
});
// Write output
if (Object.keys(output).length > 0) {
console.log(JSON.stringify(output));
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await logger.error('Hook dispatch failed', { error: errorMessage });
console.error(
JSON.stringify({
continue: false,
message: `Unexpected error: ${error}`
})
);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,219 @@
// plugin/hooks/hooks-app/src/config.ts
import * as fs from 'fs/promises';
import * as path from 'path';
import { GatesConfig, HookConfig, GateConfig } from './types';
import { fileExists } from './utils';
import { logger } from './logger';
const KNOWN_HOOK_EVENTS = [
'PreToolUse',
'PostToolUse',
'SubagentStop',
'UserPromptSubmit',
'SlashCommandStart',
'SlashCommandEnd',
'SkillStart',
'SkillEnd',
'SessionStart',
'SessionEnd',
'Stop',
'Notification'
];
const KNOWN_ACTIONS = ['CONTINUE', 'BLOCK', 'STOP'];
function validateGateConfig(gateName: string, gateConfig: GateConfig): void {
const hasPlugin = gateConfig.plugin !== undefined;
const hasGate = gateConfig.gate !== undefined;
const hasCommand = gateConfig.command !== undefined;
// plugin requires gate
if (hasPlugin && !hasGate) {
throw new Error(`Gate '${gateName}' has 'plugin' but missing 'gate' field`);
}
// gate requires plugin
if (hasGate && !hasPlugin) {
throw new Error(`Gate '${gateName}' has 'gate' but missing 'plugin' field`);
}
// command is mutually exclusive with plugin/gate
if (hasCommand && (hasPlugin || hasGate)) {
throw new Error(`Gate '${gateName}' cannot have both 'command' and 'plugin/gate'`);
}
}
/**
* Validate config invariants to catch configuration errors early.
* Throws descriptive errors when invariants are violated.
*/
export function validateConfig(config: GatesConfig): void {
// Invariant: Hook event names must be known types
for (const hookName of Object.keys(config.hooks)) {
if (!KNOWN_HOOK_EVENTS.includes(hookName)) {
throw new Error(
`Unknown hook event: ${hookName}. Must be one of: ${KNOWN_HOOK_EVENTS.join(', ')}`
);
}
}
// Invariant: Gates referenced in hooks must exist in gates config
for (const [hookName, hookConfig] of Object.entries(config.hooks)) {
if (hookConfig.gates) {
for (const gateName of hookConfig.gates) {
if (!config.gates[gateName]) {
throw new Error(`Hook '${hookName}' references undefined gate '${gateName}'`);
}
}
}
}
// Invariant: Gate actions must be CONTINUE/BLOCK/STOP or reference existing gates
for (const [gateName, gateConfig] of Object.entries(config.gates)) {
// Validate gate structure first
validateGateConfig(gateName, gateConfig);
for (const action of [gateConfig.on_pass, gateConfig.on_fail]) {
if (action && !KNOWN_ACTIONS.includes(action) && !config.gates[action]) {
throw new Error(
`Gate '${gateName}' action '${action}' is not CONTINUE/BLOCK/STOP or valid gate name`
);
}
}
}
}
/**
* Resolve plugin path using sibling convention.
* Assumes plugins are installed as siblings under the same parent directory.
*
* SECURITY: Plugin names are validated to prevent path traversal attacks.
* This does NOT mean untrusted plugins are safe - plugins are trusted by virtue
* of being explicitly installed by the user. This validation only prevents
* accidental or malicious config entries from accessing arbitrary paths.
*
* @param pluginName - Name of the plugin to resolve
* @returns Absolute path to the plugin root
* @throws Error if CLAUDE_PLUGIN_ROOT is not set or plugin name is invalid
*/
export function resolvePluginPath(pluginName: string): string {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
throw new Error('Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set');
}
// Security: Reject plugin names with path separators or parent references
// Prevents path traversal attacks like "../../../etc" or "foo/bar"
if (pluginName.includes('/') || pluginName.includes('\\') || pluginName.includes('..')) {
throw new Error(
`Invalid plugin name: '${pluginName}' (must not contain path separators)`
);
}
// Sibling convention: plugins are in same parent directory
// e.g., ~/.claude/plugins/turboshovel -> ~/.claude/plugins/cipherpowers
return path.resolve(pluginRoot, '..', pluginName);
}
/**
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
* Falls back to computing relative to this file's location.
*/
function getPluginRoot(): string | null {
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (envRoot) {
return envRoot;
}
// Fallback: compute from this file's location
// This file is at: plugin/hooks/hooks-app/src/config.ts (dev)
// Or at: plugin/hooks/hooks-app/dist/config.js (built)
// Plugin root is: plugin/
try {
return path.resolve(__dirname, '..', '..', '..');
} catch {
return null;
}
}
/**
* Load a single config file
*/
export async function loadConfigFile(configPath: string): Promise<GatesConfig | null> {
if (await fileExists(configPath)) {
const content = await fs.readFile(configPath, 'utf-8');
return JSON.parse(content);
}
return null;
}
/**
* Merge two configs. Project config takes precedence over plugin config.
* - hooks: project hooks override plugin hooks for same event
* - gates: project gates override plugin gates for same name
*/
function mergeConfigs(pluginConfig: GatesConfig, projectConfig: GatesConfig): GatesConfig {
return {
hooks: {
...pluginConfig.hooks,
...projectConfig.hooks
},
gates: {
...pluginConfig.gates,
...projectConfig.gates
}
};
}
/**
* Load and merge project and plugin configs.
*
* Priority:
* 1. Project: .claude/gates.json (highest)
* 2. Project: gates.json
* 3. Plugin: ${CLAUDE_PLUGIN_ROOT}/hooks/gates.json (fallback/defaults)
*
* Configs are MERGED - project overrides plugin for same keys.
*/
export async function loadConfig(cwd: string): Promise<GatesConfig | null> {
const pluginRoot = getPluginRoot();
// Load plugin config first (defaults)
let mergedConfig: GatesConfig | null = null;
if (pluginRoot) {
const pluginConfigPath = path.join(pluginRoot, 'hooks', 'gates.json');
const pluginConfig = await loadConfigFile(pluginConfigPath);
if (pluginConfig) {
await logger.debug('Loaded plugin gates.json', { path: pluginConfigPath });
mergedConfig = pluginConfig;
}
}
// Load project config (overrides)
const projectPaths = [
path.join(cwd, '.claude', 'gates.json'),
path.join(cwd, 'gates.json')
];
for (const configPath of projectPaths) {
const projectConfig = await loadConfigFile(configPath);
if (projectConfig) {
await logger.debug('Loaded project gates.json', { path: configPath });
if (mergedConfig) {
mergedConfig = mergeConfigs(mergedConfig, projectConfig);
await logger.debug('Merged project config with plugin config');
} else {
mergedConfig = projectConfig;
}
break; // Only load first project config found
}
}
// Validate merged config
if (mergedConfig) {
validateConfig(mergedConfig);
}
return mergedConfig;
}

View File

@@ -0,0 +1,280 @@
// plugin/hooks/hooks-app/src/context.ts
import * as fs from 'fs/promises';
import * as path from 'path';
import { HookInput } from './types';
import { fileExists } from './utils';
import { Session } from './session';
import { logger } from './logger';
/**
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
* Falls back to computing relative to this file's location.
*/
function getPluginRoot(): string | null {
// First check env var (set by Claude Code when plugin is loaded)
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (envRoot) {
return envRoot;
}
// Fallback: compute from this file's location
// This file is at: plugin/hooks/hooks-app/src/context.ts (dev)
// Or at: plugin/hooks/hooks-app/dist/context.js (built)
// Plugin root is: plugin/
try {
// Go up from src/ or dist/ -> hooks-app/ -> hooks/ -> plugin/
return path.resolve(__dirname, '..', '..', '..');
} catch {
return null;
}
}
/**
* Build context file paths for a given base directory.
* Returns array of paths following priority order:
* flat > slash-command subdir > slash-command nested > skill subdir > skill nested
*/
function buildContextPaths(baseDir: string, contextDir: string, name: string, stage: string): string[] {
return [
path.join(baseDir, contextDir, `${name}-${stage}.md`),
path.join(baseDir, contextDir, 'slash-command', `${name}-${stage}.md`),
path.join(baseDir, contextDir, 'slash-command', name, `${stage}.md`),
path.join(baseDir, contextDir, 'skill', `${name}-${stage}.md`),
path.join(baseDir, contextDir, 'skill', name, `${stage}.md`)
];
}
/**
* Discover context file following priority order.
*
* Priority (project takes precedence over plugin):
* 1. Project: .claude/context/{name}-{stage}.md (and variations)
* 2. Plugin: ${CLAUDE_PLUGIN_ROOT}/context/{name}-{stage}.md (and variations)
*/
export async function discoverContextFile(
cwd: string,
name: string,
stage: string
): Promise<string | null> {
// Project-level context (highest priority)
const projectPaths = buildContextPaths(cwd, '.claude/context', name, stage);
for (const filePath of projectPaths) {
if (await fileExists(filePath)) {
await logger.debug('Found project context file', { path: filePath, name, stage });
return filePath;
}
}
// Plugin-level context (fallback)
const pluginRoot = getPluginRoot();
if (pluginRoot) {
const pluginPaths = buildContextPaths(pluginRoot, 'context', name, stage);
for (const filePath of pluginPaths) {
if (await fileExists(filePath)) {
await logger.debug('Found plugin context file', { path: filePath, name, stage });
return filePath;
}
}
}
return null;
}
/**
* Discover agent-command scoped context file.
* Pattern: {agent}-{command}-{stage}.md
*
* Priority:
* 1. Project: {agent}-{command}-{stage}.md (most specific)
* 2. Project: {agent}-{stage}.md (agent-specific)
* 3. Plugin: {agent}-{command}-{stage}.md
* 4. Plugin: {agent}-{stage}.md
* 5. Standard discovery (backward compat, checks both project and plugin)
*/
async function discoverAgentCommandContext(
cwd: string,
agent: string,
commandOrSkill: string | null,
stage: string
): Promise<string | null> {
// Strip namespace prefix from agent name (namespace:agent-name → agent-name)
const agentName = agent.replace(/^[^:]+:/, '');
const contextName = commandOrSkill?.replace(/^\//, '').replace(/^[^:]+:/, '');
// Project-level paths (highest priority)
const projectPaths: string[] = [];
if (contextName) {
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${contextName}-${stage}.md`));
}
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${stage}.md`));
for (const filePath of projectPaths) {
if (await fileExists(filePath)) {
await logger.debug('Found project agent context file', { path: filePath, agent: agentName, stage });
return filePath;
}
}
// Plugin-level paths (fallback)
const pluginRoot = getPluginRoot();
if (pluginRoot) {
const pluginPaths: string[] = [];
if (contextName) {
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${contextName}-${stage}.md`));
}
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${stage}.md`));
for (const filePath of pluginPaths) {
if (await fileExists(filePath)) {
await logger.debug('Found plugin agent context file', { path: filePath, agent: agentName, stage });
return filePath;
}
}
}
// Backward compat: try standard discovery with command/skill name
// (discoverContextFile already checks both project and plugin)
if (contextName) {
const standardPath = await discoverContextFile(cwd, contextName, stage);
if (standardPath) {
return standardPath;
}
}
return null;
}
/**
* Extract name and stage from hook event.
* Returns { name, stage } for context file discovery.
*
* Mapping:
* - SlashCommandStart → { name: command, stage: 'start' }
* - SlashCommandEnd → { name: command, stage: 'end' }
* - SkillStart → { name: skill, stage: 'start' }
* - SkillEnd → { name: skill, stage: 'end' }
* - PreToolUse → { name: tool_name, stage: 'pre' }
* - PostToolUse → { name: tool_name, stage: 'post' }
* - SubagentStop → { name: agent_name, stage: 'end' } (special handling)
* - UserPromptSubmit → { name: 'prompt', stage: 'submit' }
* - Stop → { name: 'agent', stage: 'stop' }
* - SessionStart → { name: 'session', stage: 'start' }
* - SessionEnd → { name: 'session', stage: 'end' }
* - Notification → { name: 'notification', stage: 'receive' }
*/
function extractNameAndStage(
hookEvent: string,
input: HookInput
): { name: string; stage: string } | null {
switch (hookEvent) {
case 'SlashCommandStart':
return input.command
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'start' }
: null;
case 'SlashCommandEnd':
return input.command
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'end' }
: null;
case 'SkillStart':
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'start' } : null;
case 'SkillEnd':
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'end' } : null;
case 'PreToolUse':
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'pre' } : null;
case 'PostToolUse':
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'post' } : null;
case 'SubagentStop':
// SubagentStop has special handling - uses agent-command scoping
return null;
case 'UserPromptSubmit':
return { name: 'prompt', stage: 'submit' };
case 'Stop':
return { name: 'agent', stage: 'stop' };
case 'SessionStart':
return { name: 'session', stage: 'start' };
case 'SessionEnd':
return { name: 'session', stage: 'end' };
case 'Notification':
return { name: 'notification', stage: 'receive' };
default:
return null;
}
}
/**
* Inject context from .claude/context/ files based on hook event.
* This is the PRIMARY built-in gate - automatic context injection.
*
* Convention:
* - .claude/context/{name}-{stage}.md
* - e.g., .claude/context/code-review-start.md
* - e.g., .claude/context/prompt-submit.md
*/
export async function injectContext(hookEvent: string, input: HookInput): Promise<string | null> {
await logger.debug('Context injection starting', { event: hookEvent, cwd: input.cwd });
// Handle SubagentStop with agent-command scoping (special case)
if (hookEvent === 'SubagentStop' && input.agent_name) {
const session = new Session(input.cwd);
const activeCommand = await session.get('active_command');
const activeSkill = await session.get('active_skill');
const commandOrSkill = activeCommand || activeSkill;
const contextFile = await discoverAgentCommandContext(
input.cwd,
input.agent_name,
commandOrSkill,
'end'
);
if (contextFile) {
const content = await fs.readFile(contextFile, 'utf-8');
await logger.info('Injecting agent context', {
event: hookEvent,
agent: input.agent_name,
file: contextFile
});
return content;
}
return null;
}
// Standard context discovery for all other hooks
const extracted = extractNameAndStage(hookEvent, input);
if (!extracted) {
await logger.debug('No name/stage extracted', { event: hookEvent });
return null;
}
const { name, stage } = extracted;
const contextFile = await discoverContextFile(input.cwd, name, stage);
if (contextFile) {
const content = await fs.readFile(contextFile, 'utf-8');
await logger.info('Injecting context', {
event: hookEvent,
name,
stage,
file: contextFile
});
return content;
}
await logger.debug('No context file found', { event: hookEvent, name, stage });
return null;
}

View File

@@ -0,0 +1,260 @@
// plugin/hooks/hooks-app/src/dispatcher.ts
import { HookInput, HookConfig, GateConfig } from './types';
import { loadConfig } from './config';
import { injectContext } from './context';
import { executeGate } from './gate-loader';
import { handleAction } from './action-handler';
import { Session } from './session';
import { logger } from './logger';
export function shouldProcessHook(input: HookInput, hookConfig: HookConfig): boolean {
const hookEvent = input.hook_event_name;
// PostToolUse filtering
if (hookEvent === 'PostToolUse') {
if (hookConfig.enabled_tools && hookConfig.enabled_tools.length > 0) {
return hookConfig.enabled_tools.includes(input.tool_name || '');
}
}
// SubagentStop filtering
if (hookEvent === 'SubagentStop') {
if (hookConfig.enabled_agents && hookConfig.enabled_agents.length > 0) {
const agentName = input.agent_name || input.subagent_name || '';
return hookConfig.enabled_agents.includes(agentName);
}
}
// No filtering or other events
return true;
}
export interface DispatchResult {
context?: string;
blockReason?: string;
stopMessage?: string;
}
/**
* ERROR HANDLING: Circular gate chain prevention (max 10 gates per dispatch).
* Prevents infinite loops from misconfigured gate chains.
*/
const MAX_GATES_PER_DISPATCH = 10;
// Built-in gates removed - context injection is the primary behavior
// Context injection happens via injectContext() which discovers .claude/context/ files
/**
* Check if gate should run based on keyword matching (UserPromptSubmit only).
* Gates without keywords always run (backwards compatible).
*
* Note: Uses substring matching, not word-boundary matching. This means "test"
* will match "latest" or "contest". This is intentional for flexibility - users
* can say "let's test this" or "testing the feature" and both will match.
* If word-boundary matching is needed in the future, consider using regex like:
* /\b${keyword}\b/i.test(message)
*/
export function gateMatchesKeywords(gateConfig: GateConfig, userMessage: string | undefined): boolean {
// No keywords = always run (backwards compatible)
if (!gateConfig.keywords || gateConfig.keywords.length === 0) {
return true;
}
// No user message = skip keyword gates
if (!userMessage) {
return false;
}
const lowerMessage = userMessage.toLowerCase();
return gateConfig.keywords.some(keyword =>
lowerMessage.includes(keyword.toLowerCase())
);
}
async function updateSessionState(input: HookInput): Promise<void> {
const session = new Session(input.cwd);
const event = input.hook_event_name;
try {
switch (event) {
case 'SlashCommandStart':
if (input.command) {
await session.set('active_command', input.command);
}
break;
case 'SlashCommandEnd':
await session.set('active_command', null);
break;
case 'SkillStart':
if (input.skill) {
await session.set('active_skill', input.skill);
}
break;
case 'SkillEnd':
await session.set('active_skill', null);
break;
// Note: SubagentStart/SubagentStop NOT tracked - Claude Code does not
// provide unique agent identifiers, making reliable agent tracking impossible
// when multiple agents of the same type run in parallel.
case 'PostToolUse':
if (input.file_path) {
await session.append('edited_files', input.file_path);
// Extract and track file extension
// Edge case: ext !== input.file_path prevents tracking entire filename
// as extension when file has no dot (e.g., "README")
const ext = input.file_path.split('.').pop();
if (ext && ext !== input.file_path) {
await session.append('file_extensions', ext);
}
}
break;
}
} catch (error) {
// Session state is best-effort, don't fail the hook if it errors
// Structured error logging for debugging
const errorData = {
error_type: error instanceof Error ? error.constructor.name : 'UnknownError',
error_message: error instanceof Error ? error.message : String(error),
hook_event: event,
cwd: input.cwd,
timestamp: new Date().toISOString()
};
console.error(`[Session Error] ${JSON.stringify(errorData)}`);
}
}
export async function dispatch(input: HookInput): Promise<DispatchResult> {
const hookEvent = input.hook_event_name;
const cwd = input.cwd;
const startTime = Date.now();
await logger.event('debug', hookEvent, {
tool: input.tool_name,
agent: input.agent_name || input.subagent_name,
file: input.file_path,
cwd,
});
// Update session state (best-effort)
await updateSessionState(input);
// 1. ALWAYS run context injection FIRST (primary behavior)
// This discovers .claude/context/{name}-{stage}.md files
const contextContent = await injectContext(hookEvent, input);
let accumulatedContext = contextContent || '';
// 2. Load config for additional gates (optional)
const config = await loadConfig(cwd);
if (!config) {
await logger.debug('No gates.json config found', { cwd });
// Return context injection result even without gates.json
return accumulatedContext ? { context: accumulatedContext } : {};
}
// 3. Check if hook event has additional gates configured
const hookConfig = config.hooks[hookEvent];
if (!hookConfig) {
await logger.debug('Hook event not configured in gates.json', { event: hookEvent });
// Return context injection result even if hook not in gates.json
return accumulatedContext ? { context: accumulatedContext } : {};
}
// 4. Filter by enabled lists
if (!shouldProcessHook(input, hookConfig)) {
await logger.debug('Hook filtered out by enabled list', {
event: hookEvent,
tool: input.tool_name,
agent: input.agent_name,
});
// Still return context injection result
return accumulatedContext ? { context: accumulatedContext } : {};
}
// 5. Run additional gates in sequence (from gates.json)
const gates = hookConfig.gates || [];
let gatesExecuted = 0;
for (let i = 0; i < gates.length; i++) {
const gateName = gates[i];
// Circuit breaker: prevent infinite chains
if (gatesExecuted >= MAX_GATES_PER_DISPATCH) {
return {
blockReason: `Exceeded max gate chain depth (${MAX_GATES_PER_DISPATCH}). Check for circular references.`
};
}
const gateConfig = config.gates[gateName];
if (!gateConfig) {
// Graceful degradation: skip undefined gates with warning
accumulatedContext += `\nWarning: Gate '${gateName}' not defined, skipping`;
continue;
}
// Keyword filtering for UserPromptSubmit
if (hookEvent === 'UserPromptSubmit' && !gateMatchesKeywords(gateConfig, input.user_message)) {
await logger.debug('Gate skipped - no keyword match', { gate: gateName });
continue;
}
gatesExecuted++;
// Execute gate
const gateStartTime = Date.now();
const { passed, result } = await executeGate(gateName, gateConfig, input, []);
const gateDuration = Date.now() - gateStartTime;
await logger.event('info', hookEvent, {
gate: gateName,
passed,
duration_ms: gateDuration,
tool: input.tool_name,
});
// Determine action
const action = passed ? gateConfig.on_pass || 'CONTINUE' : gateConfig.on_fail || 'BLOCK';
// Handle action
const actionResult = await handleAction(action, result, config, input);
if (actionResult.context) {
accumulatedContext += '\n' + actionResult.context;
}
if (!actionResult.continue) {
await logger.event('warn', hookEvent, {
gate: gateName,
action,
blocked: !!actionResult.blockReason,
stopped: !!actionResult.stopMessage,
duration_ms: Date.now() - startTime,
});
return {
context: accumulatedContext,
blockReason: actionResult.blockReason,
stopMessage: actionResult.stopMessage
};
}
// Gate chaining
if (actionResult.chainedGate) {
gates.push(actionResult.chainedGate);
}
}
await logger.event('debug', hookEvent, {
status: 'completed',
gates_executed: gatesExecuted,
duration_ms: Date.now() - startTime,
});
return {
context: accumulatedContext
};
}

View File

@@ -0,0 +1,210 @@
// plugin/hooks/hooks-app/src/gate-loader.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
import { HookInput, GateResult, GateConfig, GatesConfig } from './types';
import { resolvePluginPath, loadConfigFile } from './config';
const execAsync = promisify(exec);
export interface ShellResult {
exitCode: number;
output: string;
}
/**
* Execute shell command from gate configuration with timeout.
*
* SECURITY MODEL: gates.json is trusted configuration (project-controlled, not user input).
* Commands are executed without sanitization because:
* 1. gates.json is committed to repository or managed by project admins
* 2. Users cannot inject commands without write access to gates.json
* 3. If gates.json is compromised, the project is already compromised
*
* This is equivalent to package.json scripts or Makefile targets - trusted project configuration.
*
* ERROR HANDLING: Commands timeout after 30 seconds to prevent hung gates.
*/
export async function executeShellCommand(
command: string,
cwd: string,
timeoutMs: number = 30000
): Promise<ShellResult> {
try {
const { stdout, stderr } = await execAsync(command, { cwd, timeout: timeoutMs });
return {
exitCode: 0,
output: stdout + stderr
};
} catch (error: unknown) {
const err = error as {
killed?: boolean;
signal?: string;
code?: number;
stdout?: string;
stderr?: string;
};
if (err.killed && err.signal === 'SIGTERM') {
return {
exitCode: 124, // Standard timeout exit code
output: `Command timed out after ${timeoutMs}ms`
};
}
return {
exitCode: err.code || 1,
output: (err.stdout || '') + (err.stderr || '')
};
}
}
/**
* Load and execute a built-in TypeScript gate
*
* Built-in gates are TypeScript modules in src/gates/ that export an execute function.
* Gate names use kebab-case and are mapped to camelCase module names:
* - "plugin-path" → pluginPath
* - "custom-gate" → customGate
*/
export async function executeBuiltinGate(gateName: string, input: HookInput): Promise<GateResult> {
try {
// Convert kebab-case to camelCase for module lookup
// "plugin-path" -> "pluginPath"
const moduleName = gateName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
// Import the gate module dynamically
const gates = await import('./gates');
const gateModule = (gates as any)[moduleName];
if (!gateModule || typeof gateModule.execute !== 'function') {
throw new Error(`Gate module '${moduleName}' not found or missing execute function`);
}
return await gateModule.execute(input);
} catch (error) {
throw new Error(`Failed to load built-in gate ${gateName}: ${error}`);
}
}
// Track plugin gate call stack to detect circular references
const MAX_PLUGIN_DEPTH = 10;
export async function executeGate(
gateName: string,
gateConfig: GateConfig,
input: HookInput,
pluginStack: string[] = []
): Promise<{ passed: boolean; result: GateResult }> {
// Handle plugin gate reference
if (gateConfig.plugin && gateConfig.gate) {
// Circular reference detection
const gateRef = `${gateConfig.plugin}:${gateConfig.gate}`;
if (pluginStack.includes(gateRef)) {
throw new Error(
`Circular gate reference detected: ${pluginStack.join(' -> ')} -> ${gateRef}`
);
}
// Depth limit to prevent infinite recursion
if (pluginStack.length >= MAX_PLUGIN_DEPTH) {
throw new Error(
`Maximum plugin gate depth (${MAX_PLUGIN_DEPTH}) exceeded: ${pluginStack.join(' -> ')} -> ${gateRef}`
);
}
const { gateConfig: pluginGateConfig, pluginRoot } = await loadPluginGate(
gateConfig.plugin,
gateConfig.gate
);
// Recursively execute the plugin's gate with updated stack
const newStack = [...pluginStack, gateRef];
// Execute the plugin's gate command in the plugin's directory
if (pluginGateConfig.command) {
const shellResult = await executeShellCommand(pluginGateConfig.command, pluginRoot);
const passed = shellResult.exitCode === 0;
return {
passed,
result: {
additionalContext: shellResult.output
}
};
} else if (pluginGateConfig.plugin && pluginGateConfig.gate) {
// Plugin gate references another plugin gate - recurse
return executeGate(gateRef, pluginGateConfig, input, newStack);
} else {
throw new Error(
`Plugin gate '${gateConfig.plugin}:${gateConfig.gate}' has no command`
);
}
}
if (gateConfig.command) {
// Shell command gate (existing behavior)
const shellResult = await executeShellCommand(gateConfig.command, input.cwd);
const passed = shellResult.exitCode === 0;
return {
passed,
result: {
additionalContext: shellResult.output
}
};
} else {
// Built-in TypeScript gate
const result = await executeBuiltinGate(gateName, input);
const passed = !result.decision && result.continue !== false;
return {
passed,
result
};
}
}
export interface PluginGateResult {
gateConfig: GateConfig;
pluginRoot: string;
}
/**
* Load a gate definition from another plugin.
*
* SECURITY: Plugins are trusted by virtue of being explicitly installed by the user.
* This function loads plugin configuration and does NOT validate command safety.
* The trust boundary is at plugin installation, not at gate reference.
*
* However, we do validate that the loaded config has the expected structure to
* prevent runtime errors from malformed plugin configurations.
*
* @param pluginName - Name of the plugin (e.g., 'cipherpowers')
* @param gateName - Name of the gate within the plugin
* @returns The gate config and the plugin root path for execution context
*/
export async function loadPluginGate(
pluginName: string,
gateName: string
): Promise<PluginGateResult> {
const pluginRoot = resolvePluginPath(pluginName);
const gatesPath = path.join(pluginRoot, 'hooks', 'gates.json');
const pluginConfig = await loadConfigFile(gatesPath);
if (!pluginConfig) {
throw new Error(`Cannot find gates.json for plugin '${pluginName}' at ${gatesPath}`);
}
// Validate plugin config has gates object
if (!pluginConfig.gates || typeof pluginConfig.gates !== 'object') {
throw new Error(
`Invalid gates.json structure in plugin '${pluginName}': missing or invalid 'gates' object`
);
}
const gateConfig = pluginConfig.gates[gateName];
if (!gateConfig) {
throw new Error(`Gate '${gateName}' not found in plugin '${pluginName}'`);
}
return { gateConfig, pluginRoot };
}

View File

@@ -0,0 +1,8 @@
// plugin/hooks/hooks-app/src/gates/index.ts
/**
* Built-in gates registry
*
* All TypeScript gates are exported here for easy discovery and import.
*/
export * as pluginPath from './plugin-path';

View File

@@ -0,0 +1,54 @@
// plugin/hooks/hooks-app/src/gates/plugin-path.ts
import { HookInput, GateResult } from '../types';
import * as path from 'path';
/**
* Plugin Path Injection Gate
*
* Injects CLAUDE_PLUGIN_ROOT as context for agents to resolve file references.
* This gate provides the absolute path to the plugin root directory, enabling
* agents to properly resolve @${CLAUDE_PLUGIN_ROOT}/... file references.
*
* Typical usage: SubagentStop hook to inject path context when agents complete.
*/
export async function execute(_input: HookInput): Promise<GateResult> {
// Determine plugin root:
// 1. Use CLAUDE_PLUGIN_ROOT if set (standard Claude Code environment)
// 2. Otherwise compute from this script's location
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || computePluginRoot();
const contextMessage = `## Plugin Path Context
For this session:
\`\`\`
CLAUDE_PLUGIN_ROOT=${pluginRoot}
\`\`\`
When you see file references like \`@\${CLAUDE_PLUGIN_ROOT}skills/...\`, resolve them using the path above.`;
return {
additionalContext: contextMessage
};
}
/**
* Compute plugin root from this file's location
* This file is at: plugin/hooks/hooks-app/src/gates/plugin-path.ts
* Plugin root is: plugin/
*
* We go up 4 levels: gates/ -> src/ -> hooks-app/ -> hooks/ -> plugin/
*/
function computePluginRoot(): string {
// In CommonJS, use __dirname
// __dirname is at: plugin/hooks/hooks-app/dist/gates/
// (after compilation from src/ to dist/)
// Go up 4 directories from dist/gates/
let pluginRoot = path.dirname(__dirname); // dist/
pluginRoot = path.dirname(pluginRoot); // hooks-app/
pluginRoot = path.dirname(pluginRoot); // hooks/
pluginRoot = path.dirname(pluginRoot); // plugin/
return pluginRoot;
}

View File

@@ -0,0 +1,25 @@
// plugin/hooks/hooks-app/src/index.ts
// Existing exports
export { dispatch } from './dispatcher';
export { executeGate } from './gate-loader';
export { handleAction } from './action-handler';
export { loadConfig } from './config';
export { injectContext } from './context';
export type {
HookInput,
GateResult,
GateExecute,
GateConfig,
HookConfig,
GatesConfig
} from './types';
// New session exports
export { Session } from './session';
export type { SessionState, SessionStateArrayKey, SessionStateScalarKey } from './types';
// Logging exports
export { logger } from './logger';
export type { LogLevel } from './logger';

View File

@@ -0,0 +1,180 @@
// plugin/hooks/hooks-app/src/logger.ts
import * as fs from 'fs/promises';
import * as path from 'path';
import { tmpdir } from 'os';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogEntry {
ts: string;
level: LogLevel;
event?: string;
message?: string;
[key: string]: unknown;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Get the log directory path.
* Uses ${TMPDIR}/turboshovel/ for isolation.
*/
function getLogDir(): string {
return path.join(tmpdir(), 'turboshovel');
}
/**
* Get the log file path for today.
* Format: hooks-YYYY-MM-DD.log
*/
function getLogFilePath(): string {
const date = new Date().toISOString().split('T')[0];
return path.join(getLogDir(), `hooks-${date}.log`);
}
/**
* Check if logging is enabled via environment variable.
* Logging is ENABLED by default (env vars don't pass through from Claude CLI).
* Set TURBOSHOVEL_LOG=0 to disable.
*/
function isLoggingEnabled(): boolean {
return process.env.TURBOSHOVEL_LOG !== '0';
}
/**
* Get the minimum log level from environment.
* TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error (default: info)
*/
function getMinLogLevel(): LogLevel {
const level = process.env.TURBOSHOVEL_LOG_LEVEL as LogLevel;
if (level && LOG_LEVELS[level] !== undefined) {
return level;
}
return 'info';
}
/**
* Check if a log level should be written based on minimum level.
*/
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[getMinLogLevel()];
}
/**
* Ensure the log directory exists.
*/
async function ensureLogDir(): Promise<void> {
const dir = getLogDir();
await fs.mkdir(dir, { recursive: true });
}
/**
* Write a log entry to the log file.
* Each entry is a JSON line for easy parsing with jq.
*/
async function writeLog(entry: LogEntry): Promise<void> {
if (!isLoggingEnabled()) return;
if (!shouldLog(entry.level)) return;
try {
await ensureLogDir();
const line = JSON.stringify(entry) + '\n';
await fs.appendFile(getLogFilePath(), line, 'utf-8');
} catch {
// Silently fail - logging should never break the hook
}
}
/**
* Write a log entry unconditionally (bypasses TURBOSHOVEL_LOG check).
* Used for startup/diagnostic logging to verify hooks are being invoked.
*/
async function writeLogAlways(entry: LogEntry): Promise<void> {
try {
await ensureLogDir();
const line = JSON.stringify(entry) + '\n';
await fs.appendFile(getLogFilePath(), line, 'utf-8');
} catch {
// Silently fail - logging should never break the hook
}
}
/**
* Create a log entry with timestamp.
*/
function createEntry(
level: LogLevel,
message: string,
data?: Record<string, unknown>
): LogEntry {
return {
ts: new Date().toISOString(),
level,
message,
...data,
};
}
/**
* Logger interface for hooks-app.
*
* Enable logging: TURBOSHOVEL_LOG=1
* Set level: TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error
*
* Logs are written to: ${TMPDIR}/turboshovel/hooks-YYYY-MM-DD.log
* Format: JSON lines (one JSON object per line)
*
* Example:
* {"ts":"2025-11-25T10:30:00.000Z","level":"info","event":"PostToolUse","tool":"Edit"}
*/
export const logger = {
debug: (message: string, data?: Record<string, unknown>) =>
writeLog(createEntry('debug', message, data)),
info: (message: string, data?: Record<string, unknown>) =>
writeLog(createEntry('info', message, data)),
warn: (message: string, data?: Record<string, unknown>) =>
writeLog(createEntry('warn', message, data)),
error: (message: string, data?: Record<string, unknown>) =>
writeLog(createEntry('error', message, data)),
/**
* Log unconditionally (bypasses TURBOSHOVEL_LOG check).
* Used for startup/diagnostic logging to verify hooks are invoked.
*/
always: (message: string, data?: Record<string, unknown>) =>
writeLogAlways(createEntry('info', message, data)),
/**
* Log a hook event with structured data.
* Convenience method for common hook logging pattern.
*/
event: (
level: LogLevel,
event: string,
data?: Record<string, unknown>
) =>
writeLog({
ts: new Date().toISOString(),
level,
event,
...data,
}),
/**
* Get the current log file path (for mise tasks).
*/
getLogFilePath,
/**
* Get the log directory path (for mise tasks).
*/
getLogDir,
};

View File

@@ -0,0 +1,131 @@
import { promises as fs } from 'fs';
import { dirname, join } from 'path';
import { SessionState, SessionStateArrayKey } from './types';
/**
* Manages session state with atomic file updates.
*
* State is stored in .claude/session/state.json relative to the project directory.
*/
export class Session {
private stateFile: string;
constructor(cwd: string = '.') {
this.stateFile = join(cwd, '.claude', 'session', 'state.json');
}
/**
* Get a session state value
*/
async get<K extends keyof SessionState>(key: K): Promise<SessionState[K]> {
const state = await this.load();
return state[key];
}
/**
* Set a session state value
*/
async set<K extends keyof SessionState>(key: K, value: SessionState[K]): Promise<void> {
const state = await this.load();
state[key] = value;
await this.save(state);
}
/**
* Append value to array field (deduplicated)
*/
async append(key: SessionStateArrayKey, value: string): Promise<void> {
const state = await this.load();
const array = state[key];
if (!array.includes(value)) {
array.push(value);
await this.save(state);
}
}
/**
* Check if array contains value
*/
async contains(key: SessionStateArrayKey, value: string): Promise<boolean> {
const state = await this.load();
return state[key].includes(value);
}
/**
* Clear session state (remove file)
*/
async clear(): Promise<void> {
try {
await fs.unlink(this.stateFile);
} catch (error) {
// File doesn't exist, that's fine
}
}
/**
* Load state from file or initialize new state
*/
private async load(): Promise<SessionState> {
try {
const content = await fs.readFile(this.stateFile, 'utf-8');
return JSON.parse(content);
} catch (error) {
// File doesn't exist or is corrupt, initialize new state
return this.initState();
}
}
/**
* Save state to file atomically (write to temp, then rename)
*
* Performance note: File I/O adds small overhead (~1-5ms) per operation.
* Atomic writes prevent corruption but require temp file creation.
*
* Concurrency note: Atomic rename prevents file corruption (invalid JSON,
* partial writes) but does NOT prevent logical race conditions where
* concurrent operations overwrite each other's changes. This is acceptable
* because hooks run sequentially in practice. If true concurrent access is
* needed, add file locking or retry logic.
*/
private async save(state: SessionState): Promise<void> {
await fs.mkdir(dirname(this.stateFile), { recursive: true });
const temp = this.stateFile + '.tmp';
try {
// Write to temp file
await fs.writeFile(temp, JSON.stringify(state, null, 2), 'utf-8');
// Atomic rename (prevents corruption from concurrent writes)
await fs.rename(temp, this.stateFile);
} catch (error) {
// Clean up temp file on error
try {
await fs.unlink(temp);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Initialize new session state
*
* Session ID format: ISO timestamp with punctuation replaced (e.g., "2025-11-23T14-30-45")
* Unique per millisecond. Collisions possible if multiple sessions start in same millisecond,
* but unlikely in practice due to hook serialization.
*/
private initState(): SessionState {
const now = new Date();
return {
session_id: now.toISOString().replace(/[:.]/g, '-').substring(0, 19),
started_at: now.toISOString(),
active_command: null,
active_skill: null,
edited_files: [],
file_extensions: [],
metadata: {}
};
}
}

View File

@@ -0,0 +1,102 @@
// plugin/hooks/hooks-app/src/types.ts
export interface HookInput {
hook_event_name: string;
cwd: string;
// PostToolUse
tool_name?: string;
file_path?: string;
// SubagentStop
agent_name?: string;
subagent_name?: string;
output?: string;
// UserPromptSubmit
user_message?: string;
// SlashCommand/Skill
command?: string;
skill?: string;
}
export interface GateResult {
// Success - add context and continue
additionalContext?: string;
// Block agent from proceeding
decision?: 'block';
reason?: string;
// Stop Claude entirely
continue?: false;
message?: string;
}
export type GateExecute = (input: HookInput) => Promise<GateResult>;
export interface GateConfig {
/** Reference gate from another plugin (requires gate field) */
plugin?: string;
/** Gate name within the plugin's hooks/gates.json (requires plugin field) */
gate?: string;
/** Local shell command (mutually exclusive with plugin/gate) */
command?: string;
/**
* Keywords that trigger this gate (UserPromptSubmit hook only).
* When specified, the gate only runs if the user message contains one of these keywords.
* For all other hooks (PostToolUse, SubagentStop, etc.), this field is ignored.
* Gates without keywords always run (backwards compatible).
*/
keywords?: string[];
on_pass?: string;
on_fail?: string;
}
export interface HookConfig {
enabled_tools?: string[];
enabled_agents?: string[];
gates?: string[];
}
export interface GatesConfig {
hooks: Record<string, HookConfig>;
gates: Record<string, GateConfig>;
}
// Session state interface
export interface SessionState {
/** Unique session identifier (timestamp-based) */
session_id: string;
/** ISO 8601 timestamp when session started */
started_at: string;
/** Currently active slash command (e.g., "/execute") */
active_command: string | null;
/** Currently active skill (e.g., "executing-plans") */
active_skill: string | null;
/** Files edited during this session */
edited_files: string[];
/** File extensions edited during this session (deduplicated) */
file_extensions: string[];
/** Custom metadata for specific workflows */
metadata: Record<string, any>;
}
// Note: active_agent NOT included - Claude Code does not provide unique
// agent identifiers. Use metadata field if you need custom agent tracking.
/** Array field keys in SessionState (for type-safe operations) */
export type SessionStateArrayKey = 'edited_files' | 'file_extensions';
/** Scalar field keys in SessionState */
export type SessionStateScalarKey = Exclude<keyof SessionState, SessionStateArrayKey | 'metadata'>;

View File

@@ -0,0 +1,15 @@
// plugin/hooks/hooks-app/src/utils.ts
import * as fs from 'fs/promises';
/**
* Check if a file exists at the given path.
* Used by config and context modules to probe file system.
*/
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*", "__tests__/**/*"],
"exclude": ["node_modules", "dist", "__tests__/**/*.d.ts"]
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "__tests__"]
}