claudekit
Version:
CLI tools for Claude Code development workflow
1,629 lines (1,377 loc) • 55 kB
Markdown
# Task Breakdown: Embedded Hooks System
Generated: 2025-07-31
Source: specs/feat-embedded-hooks-system.md
## Overview
Migrate existing shell script hooks to TypeScript and package them in a dedicated `claudekit-hooks` executable. This implementation will provide type safety, better testing capabilities, and consistent cross-platform behavior while maintaining the same functionality as the current shell scripts.
## Phase 1: Foundation - Core Infrastructure
### Task 1.1: Set up TypeScript project structure for hooks
**Description**: Create the directory structure and TypeScript configuration for the hooks subsystem
**Size**: Small
**Priority**: High
**Dependencies**: None
**Can run parallel with**: None (foundation task)
**Technical Requirements**:
- Create directory structure as specified in the spec
- Set up TypeScript configuration for hooks compilation
- Configure build tools for dual binary output
**Directory Structure to Create**:
```
cli/
├── hooks-cli.ts # New hooks CLI entry point
├── hooks/
│ ├── base.ts # BaseHook abstract class
│ ├── runner.ts # HookRunner class
│ ├── registry.ts # Hook registry
│ └── utils.ts # Common utilities
└── types/
└── hooks.ts # Hook-specific types
bin/
└── claudekit-hooks # New hooks CLI wrapper
```
**TypeScript Configuration**:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./cli",
"declaration": true,
"declarationMap": true
},
"include": [
"cli/**/*"
],
"exclude": [
"node_modules",
"dist",
"tests"
]
}
```
**Acceptance Criteria**:
- [ ] Directory structure created according to specification
- [ ] TypeScript configuration supports ES modules and strict mode
- [ ] Build configuration includes hooks directory
- [ ] Binary wrapper script created at bin/claudekit-hooks
### Task 1.2: Implement hooks CLI entry point
**Description**: Create the claudekit-hooks binary entry point with command routing
**Size**: Medium
**Priority**: High
**Dependencies**: Task 1.1
**Can run parallel with**: Task 1.3
**Implementation**:
```typescript
// cli/hooks-cli.ts
import { Command } from 'commander';
import { HookRunner } from './hooks/runner.js';
export function createHooksCLI(): Command {
const program = new Command('claudekit-hooks')
.description('Claude Code hooks execution system')
.version('1.0.0')
.argument('<hook>', 'Hook name to execute')
.option('--config <path>', 'Path to config file', '.claudekit/config.json')
.option('--list', 'List available hooks')
.action(async (hookName: string, options: any) => {
if (options.list) {
console.log('Available hooks:');
console.log(' typecheck - TypeScript type checking');
console.log(' no-any - Forbid any types in TypeScript');
console.log(' eslint - ESLint code validation');
console.log(' auto-checkpoint - Git auto-checkpoint on stop');
console.log(' run-related-tests - Run tests for changed files');
console.log(' project-validation - Full project validation');
console.log(' validate-todo-completion - Validate todo completions');
process.exit(0);
}
const hookRunner = new HookRunner(options.config);
const exitCode = await hookRunner.run(hookName);
process.exit(exitCode);
});
return program;
}
// Entry point
if (import.meta.url === `file://${process.argv[1]}`) {
createHooksCLI().parse(process.argv);
}
```
**Binary Wrapper (bin/claudekit-hooks)**:
```javascript
#!/usr/bin/env node
// Direct import of the hooks CLI
import('../dist/hooks-cli.js');
```
**Acceptance Criteria**:
- [ ] CLI accepts hook name as argument
- [ ] --list option displays available hooks
- [ ] --config option allows custom config path
- [ ] Binary wrapper correctly imports dist/hooks-cli.js
- [ ] Exit codes propagate correctly from hooks
### Task 1.3: Implement common hook utilities
**Description**: Create shared utilities module for all hooks with stdin reader, project root discovery, package manager detection, command execution wrapper, error formatting, and tool availability checking
**Size**: Large
**Priority**: High
**Dependencies**: Task 1.1
**Can run parallel with**: Task 1.2
**Implementation**:
```typescript
// cli/hooks/utils.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs-extra';
import * as path from 'path';
const execAsync = promisify(exec);
// Standard input reader
export async function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => resolve(data));
setTimeout(() => resolve(''), 1000); // Timeout fallback
});
}
// Project root discovery
export async function findProjectRoot(startDir: string = process.cwd()): Promise<string> {
try {
const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: startDir });
return stdout.trim();
} catch {
return process.cwd();
}
}
// Package manager detection
export interface PackageManager {
name: 'npm' | 'yarn' | 'pnpm';
exec: string;
run: string;
test: string;
}
export async function detectPackageManager(dir: string): Promise<PackageManager> {
if (await fs.pathExists(path.join(dir, 'pnpm-lock.yaml'))) {
return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' };
}
if (await fs.pathExists(path.join(dir, 'yarn.lock'))) {
return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' };
}
if (await fs.pathExists(path.join(dir, 'package.json'))) {
// Check packageManager field
try {
const pkg = await fs.readJson(path.join(dir, 'package.json'));
if (pkg.packageManager?.startsWith('pnpm')) {
return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' };
}
if (pkg.packageManager?.startsWith('yarn')) {
return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' };
}
} catch {}
}
return { name: 'npm', exec: 'npx', run: 'npm run', test: 'npm test' };
}
// Command execution wrapper
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function execCommand(
command: string,
args: string[] = [],
options: { cwd?: string; timeout?: number } = {}
): Promise<ExecResult> {
const fullCommand = `${command} ${args.join(' ')}`;
try {
const { stdout, stderr } = await execAsync(fullCommand, {
cwd: options.cwd || process.cwd(),
timeout: options.timeout || 30000,
maxBuffer: 1024 * 1024 * 10 // 10MB
});
return { stdout, stderr, exitCode: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || error.message,
exitCode: error.code || 1
};
}
}
// Error formatting
export function formatError(
title: string,
details: string,
instructions: string[]
): string {
const instructionsList = instructions
.map((inst, i) => `${i + 1}. ${inst}`)
.join('\n');
return `BLOCKED: ${title}
${details}
MANDATORY INSTRUCTIONS:
${instructionsList}`;
}
// Tool availability checking
export async function checkToolAvailable(
tool: string,
configFile: string,
projectRoot: string
): Promise<boolean> {
// Check config file exists
if (!await fs.pathExists(path.join(projectRoot, configFile))) {
return false;
}
// Check tool is executable
const pm = await detectPackageManager(projectRoot);
const result = await execCommand(pm.exec, [tool, '--version'], {
cwd: projectRoot,
timeout: 10000
});
return result.exitCode === 0;
}
```
**Acceptance Criteria**:
- [ ] readStdin with 1-second timeout implemented
- [ ] Project root discovery via git rev-parse
- [ ] Package manager detection for npm/yarn/pnpm
- [ ] Command execution with timeout and output capture
- [ ] Error formatting follows BLOCKED: pattern
- [ ] Tool availability checker works correctly
### Task 1.4: Implement base hook class
**Description**: Create the abstract base hook class that all hooks extend, incorporating common patterns for input processing, execution flow, and output handling
**Size**: Large
**Priority**: High
**Dependencies**: Task 1.3
**Can run parallel with**: None
**Implementation**:
```typescript
// cli/hooks/base.ts
import * as fs from 'fs-extra';
import * as path from 'path';
import { execCommand, detectPackageManager, formatError, findProjectRoot } from './utils.js';
import type { ExecResult, PackageManager } from './utils.js';
export interface ClaudePayload {
tool_input?: {
file_path?: string;
[key: string]: any;
};
stop_hook_active?: boolean;
transcript_path?: string;
[key: string]: any;
}
export interface HookContext {
filePath?: string;
projectRoot: string;
payload: ClaudePayload;
packageManager: PackageManager;
}
export interface HookResult {
exitCode: number;
suppressOutput?: boolean;
jsonResponse?: any;
}
export interface HookConfig {
command?: string;
timeout?: number;
[key: string]: any; // Hook-specific config
}
export abstract class BaseHook {
abstract name: string;
protected config: HookConfig;
constructor(config: HookConfig = {}) {
this.config = config;
}
// Main execution method - implements common flow
async run(payload: ClaudePayload): Promise<HookResult> {
// Check for infinite loop prevention
if (payload.stop_hook_active) {
return { exitCode: 0 };
}
// Extract file path if present
const filePath = payload.tool_input?.file_path;
// Find project root
const projectRoot = await findProjectRoot(filePath ? path.dirname(filePath) : process.cwd());
// Detect package manager
const packageManager = await detectPackageManager(projectRoot);
// Create context
const context: HookContext = {
filePath,
projectRoot,
payload,
packageManager
};
// Execute hook-specific logic
return this.execute(context);
}
// Hook-specific implementation
abstract execute(context: HookContext): Promise<HookResult>;
// Common utilities
protected async execCommand(
command: string,
args: string[] = [],
options?: { cwd?: string; timeout?: number }
): Promise<ExecResult> {
return execCommand(command, args, {
timeout: this.config.timeout,
...options
});
}
// Progress message to stderr
protected progress(message: string): void {
console.error(message);
}
// Success message to stderr
protected success(message: string): void {
console.error(`✅ ${message}`);
}
// Warning message to stderr
protected warning(message: string): void {
console.error(`⚠️ ${message}`);
}
// Error output with instructions
protected error(title: string, details: string, instructions: string[]): void {
console.error(formatError(title, details, instructions));
}
// Silent JSON output
protected jsonOutput(data: any): void {
console.log(JSON.stringify(data));
}
// File operations
protected async fileExists(filePath: string): Promise<boolean> {
return fs.pathExists(filePath);
}
protected async readFile(filePath: string): Promise<string> {
return fs.readFile(filePath, 'utf-8');
}
// Skip conditions
protected shouldSkipFile(filePath: string | undefined, extensions: string[]): boolean {
if (!filePath) return true;
if (!extensions.some(ext => filePath.endsWith(ext))) return true;
return false;
}
}
```
**Acceptance Criteria**:
- [ ] Base class implements common execution flow
- [ ] Context creation with project root and package manager
- [ ] Utility methods for progress, success, warning, error
- [ ] File operations helpers implemented
- [ ] Skip condition helpers for file type checking
- [ ] JSON output method for Stop hooks
- [ ] Exit code handling (0 for success/skip, 2 for blocking)
### Task 1.5: Implement hook runner and configuration
**Description**: Build the hook runner that manages hook execution, configuration loading, and registry
**Size**: Medium
**Priority**: High
**Dependencies**: Task 1.4
**Can run parallel with**: None
**Implementation**:
```typescript
// cli/hooks/runner.ts
import * as fs from 'fs-extra';
import * as path from 'path';
import { z } from 'zod';
import { readStdin } from './utils.js';
import { BaseHook, HookResult } from './base.js';
// Configuration schema
const HookConfigSchema = z.object({
command: z.string().optional(),
timeout: z.number().optional().default(30000),
}).passthrough();
const ConfigSchema = z.object({
hooks: z.record(z.string(), HookConfigSchema).optional().default({}),
});
export class HookRunner {
private hooks: Map<string, new (config: any) => BaseHook> = new Map();
private configPath: string;
constructor(configPath: string = '.claudekit/config.json') {
this.configPath = configPath;
// Registry will be populated in Phase 2
}
async run(hookName: string): Promise<number> {
// Get hook class
const HookClass = this.hooks.get(hookName);
if (!HookClass) {
console.error(`Unknown hook: ${hookName}`);
return 1;
}
// Load configuration
const config = await this.loadConfig();
const hookConfig = config.hooks[hookName] || {};
// Read Claude payload from stdin
const input = await readStdin();
let payload;
try {
payload = JSON.parse(input || '{}');
} catch {
payload = {};
}
// Create and run hook
const hook = new HookClass(hookConfig);
const result = await hook.run(payload);
// Handle different result types
if (result.jsonResponse) {
console.log(JSON.stringify(result.jsonResponse));
}
return result.exitCode;
}
private async loadConfig(): Promise<z.infer<typeof ConfigSchema>> {
try {
const configPath = path.resolve(this.configPath);
const configData = await fs.readJson(configPath);
return ConfigSchema.parse(configData);
} catch {
// Return default config if file doesn't exist or is invalid
return { hooks: {} };
}
}
}
```
**Configuration Type Definitions**:
```typescript
// cli/types/hooks.ts
import { z } from 'zod';
// Base hook configuration that all hooks share
export const BaseHookConfigSchema = z.object({
command: z.string().optional(),
timeout: z.number().optional().default(30000),
}).passthrough(); // Allow hook-specific fields like 'pattern', 'prefix', etc.
// Complete configuration schema
export const ClaudekitConfigSchema = z.object({
hooks: z.record(z.string(), BaseHookConfigSchema).optional().default({}),
});
export type HookConfig = z.infer<typeof BaseHookConfigSchema>;
export type ClaudekitConfig = z.infer<typeof ClaudekitConfigSchema>;
```
**Acceptance Criteria**:
- [ ] Hook runner loads configuration from .claudekit/config.json
- [ ] Configuration validated with Zod schema
- [ ] Stdin payload parsed and passed to hooks
- [ ] Unknown hook names return exit code 1
- [ ] JSON responses handled for Stop hooks
- [ ] Default configuration returned when file missing
### Task 1.6: Update build system for dual binaries
**Description**: Configure the build system to produce both claudekit and claudekit-hooks binaries
**Size**: Medium
**Priority**: High
**Dependencies**: Task 1.2
**Can run parallel with**: None
**Package.json Updates**:
```json
{
"name": "claudekit",
"version": "0.1.5",
"bin": {
"claudekit": "./bin/claudekit",
"claudekit-hooks": "./bin/claudekit-hooks"
},
"scripts": {
"build": "npm run clean && npm run build:main && npm run build:hooks && npm run build:types",
"build:main": "esbuild cli/cli.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/cli.js --external:node:* --packages=external",
"build:hooks": "esbuild cli/hooks-cli.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/hooks-cli.js --external:node:* --packages=external",
"build:types": "tsc --project tsconfig.build.json --emitDeclarationOnly",
"build:hooks-dev": "tsc cli/hooks-cli.ts --outDir dist --module esnext --target es2022 --moduleResolution node --skipLibCheck"
}
}
```
**Build Configuration**:
```typescript
// build.config.ts
import { build } from 'esbuild';
const commonOptions = {
bundle: true,
platform: 'node' as const,
target: 'node20',
format: 'esm' as const,
external: ['node:*'],
packages: 'external' as const,
sourcemap: true,
minify: process.env.NODE_ENV === 'production',
};
// Build main CLI
await build({
...commonOptions,
entryPoints: ['cli/cli.ts'],
outfile: 'dist/cli.js',
});
// Build hooks CLI
await build({
...commonOptions,
entryPoints: ['cli/hooks-cli.ts'],
outfile: 'dist/hooks-cli.js',
});
```
**Acceptance Criteria**:
- [ ] Package.json includes both binary entries
- [ ] Build scripts compile both CLIs separately
- [ ] ESBuild configuration for production builds
- [ ] TypeScript compilation for development builds
- [ ] External node modules properly configured
- [ ] Source maps generated for debugging
## Phase 2: Core Features - Hook Implementations
### Task 2.1: Implement TypeScript compiler hook
**Description**: Port the TypeScript compilation checking functionality to the new system
**Size**: Medium
**Priority**: High
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.2, 2.3, 2.4
**Implementation**:
```typescript
// cli/hooks/typecheck.ts
import { BaseHook, HookContext, HookResult } from './base.js';
import { checkToolAvailable } from './utils.js';
export class TypecheckHook extends BaseHook {
name = 'typecheck';
async execute(context: HookContext): Promise<HookResult> {
const { filePath, projectRoot, packageManager } = context;
// Skip if no file or wrong extension
if (this.shouldSkipFile(filePath, ['.ts', '.tsx'])) {
return { exitCode: 0 };
}
// Check if TypeScript is available
if (!await checkToolAvailable('tsc', 'tsconfig.json', projectRoot)) {
this.warning('No TypeScript configuration found, skipping check');
return { exitCode: 0 };
}
this.progress(`📘 Type-checking ${filePath}`);
// Run TypeScript compiler
const command = this.config.command || `${packageManager.exec} tsc --noEmit`;
const result = await this.execCommand(command, [], {
cwd: projectRoot
});
if (result.exitCode !== 0) {
this.error(
'TypeScript compilation failed',
result.stderr || result.stdout,
[
'Fix ALL TypeScript errors shown above',
'Run the project\'s type check command to verify all errors are resolved',
'(Check AGENT.md/CLAUDE.md or package.json scripts for the exact command)'
]
);
return { exitCode: 2 };
}
this.success('TypeScript check passed!');
return { exitCode: 0 };
}
}
```
**Acceptance Criteria**:
- [ ] Checks only .ts and .tsx files
- [ ] Skips when no tsconfig.json present
- [ ] Uses project's package manager for execution
- [ ] Respects custom command from config
- [ ] Returns exit code 2 on compilation errors
- [ ] Clear error message with fix instructions
### Task 2.2: Implement no-any types hook
**Description**: Create hook that forbids the use of 'any' types in TypeScript files
**Size**: Medium
**Priority**: High
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.1, 2.3, 2.4
**Implementation**:
```typescript
// cli/hooks/no-any.ts
import { BaseHook, HookContext, HookResult } from './base.js';
export class NoAnyHook extends BaseHook {
name = 'no-any';
async execute(context: HookContext): Promise<HookResult> {
const { filePath } = context;
// Skip if no file or wrong extension
if (this.shouldSkipFile(filePath, ['.ts', '.tsx'])) {
return { exitCode: 0 };
}
this.progress(`🚫 Checking for 'any' types in ${filePath}`);
const content = await this.readFile(filePath!);
const lines = content.split('\n');
const errors: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
// Skip comments and test utilities
if (line.trim().startsWith('//') ||
line.trim().startsWith('*') ||
line.includes('expect.any(') ||
line.includes('.any(')) {
continue;
}
// Check for forbidden 'any' patterns
const anyPattern = /:\s*any\b|:\s*any\[\]|<any>|as\s+any\b|=\s*any\b/;
if (anyPattern.test(line)) {
errors.push(`Line ${lineNum}: ${line.trim()}`);
}
}
if (errors.length > 0) {
this.error(
'Forbidden \'any\' types detected',
`❌ File contains ${errors.length} forbidden 'any' type${errors.length > 1 ? 's' : ''}:\n\n${errors.join('\n')}`,
[
'Replace ALL \'any\' types with proper types',
'Use specific interfaces, union types, or generics instead of \'any\'',
'Examples of fixes:',
' - Instead of: data: any → Define: interface Data { ... }',
' - Instead of: items: any[] → Use: items: Item[] or items: Array<{id: string, name: string}>',
' - Instead of: value: any → Use: value: string | number | boolean',
' - Instead of: response: any → Use: response: unknown (then add type guards)'
]
);
return { exitCode: 2 };
}
this.success('No forbidden \'any\' types found!');
return { exitCode: 0 };
}
}
```
**Acceptance Criteria**:
- [ ] Detects various 'any' type patterns
- [ ] Skips comments and test utilities (expect.any)
- [ ] Reports line numbers with errors
- [ ] Provides specific fix examples
- [ ] Returns exit code 2 when 'any' found
- [ ] Clear success message when clean
### Task 2.3: Implement ESLint hook
**Description**: Port ESLint validation to check JavaScript and TypeScript files
**Size**: Large
**Priority**: High
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.1, 2.2, 2.4
**Implementation (Part 1)**:
```typescript
// cli/hooks/eslint.ts (part 1)
import * as path from 'path';
import { BaseHook, HookContext, HookResult } from './base.js';
export class EslintHook extends BaseHook {
name = 'eslint';
async execute(context: HookContext): Promise<HookResult> {
const { filePath, projectRoot } = context;
// Skip if no file path or not JavaScript/TypeScript file
if (!filePath || !filePath.match(/\.(js|jsx|ts|tsx)$/)) {
return { exitCode: 0, skipped: true };
}
// Check if ESLint is configured
if (!await this.hasEslint(projectRoot)) {
this.log('info', 'ESLint not configured, skipping lint check');
return { exitCode: 0, skipped: true };
}
this.log('info', `Running ESLint on ${filePath}...`);
// Run ESLint
const eslintResult = await this.runEslint(filePath, projectRoot);
if (eslintResult.code !== 0 || this.hasEslintErrors(eslintResult.stdout)) {
const errorMessage = this.formatEslintErrors(eslintResult.stdout || eslintResult.stderr);
this.outputError('ESLint check failed', errorMessage);
return { exitCode: 2 };
}
this.log('info', 'ESLint check passed!');
return { exitCode: 0 };
}
```
**Implementation (Part 2)**:
```typescript
// cli/hooks/eslint.ts (part 2)
private async hasEslint(projectRoot: string): Promise<boolean> {
// Check for ESLint config files
const configFiles = [
'.eslintrc.json',
'.eslintrc.js',
'.eslintrc.yml',
'.eslintrc.yaml',
'eslint.config.js',
'eslint.config.mjs'
];
for (const configFile of configFiles) {
if (await this.fileExists(path.join(projectRoot, configFile))) {
// Verify ESLint is available
const pm = await this.detectPackageManager(projectRoot);
const pmExec = this.getPackageManagerExec(pm);
const result = await this.exec(`${pmExec} --quiet eslint --version`, {
cwd: projectRoot,
timeout: 10000
});
return result.code === 0;
}
}
return false;
}
private async runEslint(filePath: string, projectRoot: string): Promise<any> {
const pm = await this.detectPackageManager(projectRoot);
const pmExec = this.getPackageManagerExec(pm);
const eslintCommand = this.config.command || `${pmExec} eslint`;
// Build ESLint arguments
const eslintArgs: string[] = [];
// Add file extensions if configured
if (this.config.extensions) {
eslintArgs.push(`--ext ${this.config.extensions.join(',')}`);
}
// Add fix flag if configured
if (this.config.fix) {
eslintArgs.push('--fix');
}
// Add the file path
eslintArgs.push(`"${filePath}"`);
const fullCommand = `${eslintCommand} ${eslintArgs.join(' ')}`;
return await this.exec(fullCommand, {
cwd: projectRoot,
timeout: this.config.timeout || 30000
});
}
private hasEslintErrors(output: string): boolean {
return output.includes('error') || output.includes('warning');
}
private formatEslintErrors(output: string): string {
return `
${output}
MANDATORY INSTRUCTIONS:
You MUST fix ALL lint errors and warnings shown above.
REQUIRED ACTIONS:
1. Fix all errors shown above
2. Run the project's lint command to verify all issues are resolved
(Check AGENT.md/CLAUDE.md or package.json scripts for the exact command)
3. Common fixes:
- Missing semicolons or trailing commas
- Unused variables (remove or use them)
- Console.log statements (remove from production code)
- Improper indentation or spacing`;
}
}
```
**Note**: Some method names need adjustment to match BaseHook (log → progress, exec → execCommand, etc.)
**Acceptance Criteria**:
- [ ] Checks .js, .jsx, .ts, .tsx files
- [ ] Detects ESLint configuration files
- [ ] Verifies ESLint is executable
- [ ] Supports custom command from config
- [ ] Supports fix flag and extensions config
- [ ] Returns exit code 2 on lint errors
- [ ] Clear error formatting with common fixes
### Task 2.4: Implement auto-checkpoint hook
**Description**: Create git auto-checkpoint functionality that creates timestamped stashes on Stop events
**Size**: Large
**Priority**: High
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.1, 2.2, 2.3
**Implementation**:
```typescript
// cli/hooks/auto-checkpoint.ts
import { BaseHook, HookContext, HookResult } from './base.js';
export class AutoCheckpointHook extends BaseHook {
name = 'auto-checkpoint';
async execute(context: HookContext): Promise<HookResult> {
const { projectRoot } = context;
const prefix = this.config.prefix || 'claude';
const maxCheckpoints = this.config.maxCheckpoints || 10;
// Check if there are any changes to checkpoint
const { stdout } = await this.execCommand('git', ['status', '--porcelain'], {
cwd: projectRoot
});
if (!stdout.trim()) {
// No changes, suppress output
return { exitCode: 0, suppressOutput: true };
}
// Create checkpoint with timestamp
const timestamp = new Date().toISOString();
const message = `${prefix}-checkpoint: Auto-save at ${timestamp}`;
// Add all files temporarily
await this.execCommand('git', ['add', '-A'], { cwd: projectRoot });
// Create stash object without modifying working directory
const { stdout: stashSha } = await this.execCommand(
'git',
['stash', 'create', message],
{ cwd: projectRoot }
);
if (stashSha.trim()) {
// Store the stash in the stash list
await this.execCommand(
'git',
['stash', 'store', '-m', message, stashSha.trim()],
{ cwd: projectRoot }
);
// Reset index to unstage files
await this.execCommand('git', ['reset'], { cwd: projectRoot });
// Clean up old checkpoints if needed
await this.cleanupOldCheckpoints(prefix, maxCheckpoints, projectRoot);
}
// Silent success
return {
exitCode: 0,
suppressOutput: true,
jsonResponse: { suppressOutput: true }
};
}
private async cleanupOldCheckpoints(
prefix: string,
maxCount: number,
projectRoot: string
): Promise<void> {
// Get list of checkpoints
const { stdout } = await this.execCommand(
'git',
['stash', 'list'],
{ cwd: projectRoot }
);
const checkpoints = stdout
.split('\n')
.filter(line => line.includes(`${prefix}-checkpoint`))
.map((line, index) => ({ line, index }));
// Remove old checkpoints if over limit
if (checkpoints.length > maxCount) {
const toRemove = checkpoints.slice(maxCount);
for (const checkpoint of toRemove.reverse()) {
await this.execCommand(
'git',
['stash', 'drop', `stash@{${checkpoint.index}}`],
{ cwd: projectRoot }
);
}
}
}
}
```
**Acceptance Criteria**:
- [ ] Detects uncommitted changes using git status --porcelain
- [ ] Creates stash without modifying working directory
- [ ] Uses configured prefix (default: 'claude')
- [ ] Includes ISO timestamp in message
- [ ] Cleans up old checkpoints over limit
- [ ] Returns silent JSON response for Stop hook
- [ ] Handles case when no changes present
### Task 2.5: Implement run-related-tests hook
**Description**: Run tests related to changed files automatically
**Size**: Large
**Priority**: Medium
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.6, 2.7
**Implementation (Part 1)**:
```typescript
// cli/hooks/run-related-tests.ts (part 1)
import * as path from 'path';
import { BaseHook, HookContext, HookResult } from './base.js';
export class RunRelatedTestsHook extends BaseHook {
name = 'run-related-tests';
async execute(context: HookContext): Promise<HookResult> {
const { filePath, projectRoot, packageManager } = context;
// Skip if no file path
if (!filePath) {
return { exitCode: 0 };
}
// Only run tests for source files
if (!filePath.match(/\.(js|jsx|ts|tsx)$/)) {
return { exitCode: 0 };
}
// Skip test files themselves
if (filePath.match(/\.(test|spec)\.(js|jsx|ts|tsx)$/)) {
return { exitCode: 0 };
}
this.progress(`🧪 Running tests related to: ${filePath}...`);
// Find related test files
const testFiles = await this.findRelatedTestFiles(filePath);
if (testFiles.length === 0) {
this.warning(`No test files found for ${path.basename(filePath)}`);
this.warning(`Consider creating tests in: ${path.dirname(filePath)}/${path.basename(filePath, path.extname(filePath))}.test${path.extname(filePath)}`);
return { exitCode: 0 };
}
this.progress(`Found related test files: ${testFiles.join(', ')}`);
// Run tests
const testCommand = this.config.command || `${packageManager.test}`;
const result = await this.execCommand(testCommand, ['--', ...testFiles], {
cwd: projectRoot
});
```
**Implementation (Part 2)**:
```typescript
// cli/hooks/run-related-tests.ts (part 2)
if (result.exitCode !== 0) {
this.error(
`Tests failed for ${filePath}`,
result.stdout + result.stderr,
[
'You MUST fix ALL test failures, regardless of whether they seem related to your recent changes',
'First, examine the failing test output above to understand what\'s broken',
`Run the failing tests individually for detailed output: ${testCommand} -- ${testFiles.join(' ')}`,
`Then run ALL tests to ensure nothing else is broken: ${testCommand}`,
'Fix ALL failing tests by:',
' - Reading each test to understand its purpose',
' - Determining if the test or the implementation is wrong',
' - Updating whichever needs to change to match expected behavior',
' - NEVER skip, comment out, or use .skip() to bypass tests',
'Common fixes to consider:',
' - Update mock data to match new types/interfaces',
' - Fix async timing issues with proper await/waitFor',
' - Update component props in tests to match changes',
' - Ensure test database/state is properly reset',
' - Check if API contracts have changed'
]
);
return { exitCode: 2 };
}
this.success('All related tests passed!');
return { exitCode: 0 };
}
private async findRelatedTestFiles(filePath: string): Promise<string[]> {
const baseName = path.basename(filePath, path.extname(filePath));
const dirName = path.dirname(filePath);
const ext = path.extname(filePath);
// Common test file patterns
const testPatterns = [
`${dirName}/${baseName}.test${ext}`,
`${dirName}/${baseName}.spec${ext}`,
`${dirName}/__tests__/${baseName}.test${ext}`,
`${dirName}/__tests__/${baseName}.spec${ext}`,
];
const foundFiles: string[] = [];
for (const pattern of testPatterns) {
if (await this.fileExists(pattern)) {
foundFiles.push(pattern);
}
}
return foundFiles;
}
}
```
**Acceptance Criteria**:
- [ ] Runs tests only for source files (not test files)
- [ ] Finds test files using common patterns
- [ ] Warns when no tests found with suggestion
- [ ] Runs tests with proper package manager
- [ ] Detailed error instructions for failures
- [ ] Exit code 2 on test failures
- [ ] Supports custom test command from config
### Task 2.6: Implement project validation hook
**Description**: Create comprehensive project-wide validation running TypeScript, ESLint, and tests
**Size**: Large
**Priority**: Medium
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.5, 2.7
**Implementation (Part 1)**:
```typescript
// cli/hooks/project-validation.ts (part 1)
import { BaseHook, HookContext, HookResult } from './base.js';
import { checkToolAvailable } from './utils.js';
export class ProjectValidationHook extends BaseHook {
name = 'project-validation';
async execute(context: HookContext): Promise<HookResult> {
const { projectRoot, packageManager } = context;
this.progress('Running project-wide validation...');
let hasFailures = false;
let validationOutput = '';
// Run TypeScript check if available
if (await checkToolAvailable('tsc', 'tsconfig.json', projectRoot)) {
validationOutput += '📘 Running TypeScript validation...\n';
const tsCommand = this.config.typescriptCommand || `${packageManager.exec} tsc --noEmit`;
const tsResult = await this.execCommand(tsCommand, [], { cwd: projectRoot });
if (tsResult.exitCode === 0) {
validationOutput += '✅ TypeScript validation passed\n\n';
} else {
hasFailures = true;
validationOutput += '❌ TypeScript validation failed:\n';
validationOutput += this.indent(tsResult.stderr || tsResult.stdout) + '\n\n';
}
}
// Run ESLint if available
if (await checkToolAvailable('eslint', '.eslintrc.json', projectRoot)) {
validationOutput += '🔍 Running ESLint validation...\n';
const eslintCommand = this.config.eslintCommand || `${packageManager.exec} eslint . --ext .js,.jsx,.ts,.tsx`;
const eslintResult = await this.execCommand(eslintCommand, [], { cwd: projectRoot });
if (eslintResult.exitCode === 0 && !eslintResult.stdout.includes('error')) {
validationOutput += '✅ ESLint validation passed\n\n';
} else {
hasFailures = true;
validationOutput += '❌ ESLint validation failed:\n';
validationOutput += this.indent(eslintResult.stdout) + '\n\n';
}
}
```
**Implementation (Part 2)**:
```typescript
// cli/hooks/project-validation.ts (part 2)
// Run tests if available
const { stdout: pkgJson } = await this.execCommand('cat', ['package.json'], { cwd: projectRoot });
if (pkgJson.includes('"test"')) {
validationOutput += '🧪 Running test suite...\n';
const testCommand = this.config.testCommand || packageManager.test;
const testResult = await this.execCommand(testCommand, [], { cwd: projectRoot });
if (testResult.exitCode === 0 && !testResult.stdout.match(/FAIL|failed|Error:|failing/)) {
validationOutput += '✅ Test suite passed\n\n';
} else {
hasFailures = true;
validationOutput += '❌ Test suite failed:\n';
validationOutput += this.indent(testResult.stdout + testResult.stderr) + '\n\n';
}
}
// Output results
if (hasFailures) {
// Build list of failed checks
const failedChecks: string[] = [];
if (validationOutput.includes('❌ TypeScript validation failed')) {
failedChecks.push('Type checking command');
}
if (validationOutput.includes('❌ ESLint validation failed')) {
failedChecks.push('Lint command');
}
if (validationOutput.includes('❌ Test suite failed')) {
failedChecks.push('Test command');
}
console.error(`████ Project Validation Failed ████
Your implementation has validation errors that must be fixed:
${validationOutput}
REQUIRED ACTIONS:
1. Fix all errors shown above
2. Run the failed validation commands to verify fixes:
${failedChecks.map(check => ` - ${check}`).join('\n')}
(Check AGENT.md/CLAUDE.md or package.json scripts for exact commands)
3. Make necessary corrections
4. The validation will run again automatically`);
return { exitCode: 2 };
}
this.success('All validations passed! Great work!');
return { exitCode: 0 };
}
private indent(text: string, spaces: number = 2): string {
return text.split('\n').map(line => ' '.repeat(spaces) + line).join('\n');
}
}
```
**Acceptance Criteria**:
- [ ] Runs TypeScript, ESLint, and tests if available
- [ ] Checks tool availability before running
- [ ] Aggregates all validation results
- [ ] Clear output showing pass/fail for each check
- [ ] Lists failed commands for user to run
- [ ] Exit code 2 if any validation fails
- [ ] Supports custom commands from config
### Task 2.7: Implement validate-todo-completion hook
**Description**: Validate that all todos are completed before allowing Stop events
**Size**: Medium
**Priority**: Medium
**Dependencies**: Task 1.5
**Can run parallel with**: Task 2.5, 2.6
**Implementation**:
```typescript
// cli/hooks/validate-todo.ts
import * as path from 'path';
import { BaseHook, HookContext, HookResult } from './base.js';
interface Todo {
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
export class ValidateTodoCompletionHook extends BaseHook {
name = 'validate-todo-completion';
async execute(context: HookContext): Promise<HookResult> {
const { payload } = context;
// Get transcript path
let transcriptPath = payload.transcript_path;
if (!transcriptPath) {
// Allow stop - no transcript to check
return { exitCode: 0 };
}
// Expand ~ to home directory
transcriptPath = transcriptPath.replace(/^~/, process.env.HOME || '');
if (!await this.fileExists(transcriptPath)) {
// Allow stop - transcript not found
return { exitCode: 0 };
}
// Find the most recent todo state
const todoState = await this.findLatestTodoState(transcriptPath);
if (!todoState) {
// No todos found, allow stop
return { exitCode: 0 };
}
// Check for incomplete todos
const incompleteTodos = todoState.filter(todo => todo.status !== 'completed');
if (incompleteTodos.length > 0) {
// Block stop and return JSON response
const reason = `You have ${incompleteTodos.length} incomplete todo items. You must complete all tasks before stopping:
${incompleteTodos.map(todo => ` - [${todo.status}] ${todo.content}`).join('\n')}
Use TodoRead to see the current status, then complete all remaining tasks. Mark each task as completed using TodoWrite as you finish them.`;
this.jsonOutput({
decision: 'block',
reason: reason
});
return { exitCode: 0 }; // Note: exit 0 for Stop hooks, JSON controls decision
}
// All todos complete, allow stop
return { exitCode: 0 };
}
private async findLatestTodoState(transcriptPath: string): Promise<Todo[] | null> {
const content = await this.readFile(transcriptPath);
const lines = content.split('\n').filter(line => line.trim());
// Read from end to find most recent todo state
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
try {
const entry = JSON.parse(line);
if (entry.toolUseResult?.newTodos && Array.isArray(entry.toolUseResult.newTodos)) {
return entry.toolUseResult.newTodos;
}
} catch {
// Not valid JSON, continue
}
}
return null;
}
}
```
**Acceptance Criteria**:
- [ ] Reads transcript path from payload
- [ ] Expands ~ to home directory
- [ ] Finds most recent todo state in transcript
- [ ] Blocks stop if incomplete todos exist
- [ ] Returns JSON response for Stop hook
- [ ] Clear reason message with todo list
- [ ] Exit code 0 (JSON controls decision)
### Task 2.8: Create hook registry
**Description**: Implement the hook registry that maps hook names to their implementations
**Size**: Small
**Priority**: High
**Dependencies**: Task 2.1-2.7
**Can run parallel with**: None
**Implementation**:
```typescript
// cli/hooks/registry.ts
import { TypecheckHook } from './typecheck.js';
import { NoAnyHook } from './no-any.js';
import { EslintHook } from './eslint.js';
import { AutoCheckpointHook } from './auto-checkpoint.js';
import { RunRelatedTestsHook } from './run-related-tests.js';
import { ProjectValidationHook } from './project-validation.js';
import { ValidateTodoCompletionHook } from './validate-todo.js';
export const HOOK_REGISTRY = {
'typecheck': TypecheckHook,
'no-any': NoAnyHook,
'eslint': EslintHook,
'auto-checkpoint': AutoCheckpointHook,
'run-related-tests': RunRelatedTestsHook,
'project-validation': ProjectValidationHook,
'validate-todo-completion': ValidateTodoCompletionHook,
};
export type HookName = keyof typeof HOOK_REGISTRY;
```
**Update Hook Runner**:
```typescript
// In cli/hooks/runner.ts constructor:
constructor(configPath: string = '.claudekit/config.json') {
this.configPath = configPath;
// Register all hooks
this.hooks.set('typecheck', TypecheckHook);
this.hooks.set('no-any', NoAnyHook);
this.hooks.set('eslint', EslintHook);
this.hooks.set('auto-checkpoint', AutoCheckpointHook);
this.hooks.set('run-related-tests', RunRelatedTestsHook);
this.hooks.set('project-validation', ProjectValidationHook);
this.hooks.set('validate-todo-completion', ValidateTodoCompletionHook);
}
```
**Acceptance Criteria**:
- [ ] Registry exports all hook classes
- [ ] Type definition for valid hook names
- [ ] Hook runner imports and registers all hooks
- [ ] All hooks accessible by name
## Phase 3: Testing and Quality
### Task 3.1: Create unit tests for base infrastructure
**Description**: Write unit tests for BaseHook, HookRunner, and utilities
**Size**: Large
**Priority**: High
**Dependencies**: Phase 1 complete
**Can run parallel with**: Task 3.2
**Test Structure**:
```typescript
// tests/hooks/unit/base.test.ts
import { describe, it, expect, vi } from 'vitest';
import { BaseHook } from '../../../cli/hooks/base.js';
class TestHook extends BaseHook {
name = 'test';
async execute(context): Promise<HookResult> {
return { exitCode: 0 };
}
}
describe('BaseHook', () => {
it('should handle infinite loop prevention', async () => {
const hook = new TestHook({});
const result = await hook.run({ stop_hook_active: true });
expect(result.exitCode).toBe(0);
});
it('should extract file path from payload', async () => {
const hook = new TestHook({});
vi.spyOn(hook as any, 'execute').mockResolvedValue({ exitCode: 0 });
await hook.run({ tool_input: { file_path: '/test/file.ts' } });
expect(hook.execute).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/test/file.ts'
})
);
});
});
// tests/hooks/unit/utils.test.ts
describe('readStdin', () => {
it('should timeout after 1 second', async () => {
const start = Date.now();
const result = await readStdin();
const duration = Date.now() - start;
expect(result).toBe('');
expect(duration).toBeGreaterThan(900);
expect(duration).toBeLessThan(1100);
});
});
```
**Acceptance Criteria**:
- [ ] Test BaseHook common flow
- [ ] Test utility functions (readStdin, findProjectRoot, etc.)
- [ ] Test HookRunner configuration loading
- [ ] Mock file system and command execution
- [ ] Verify exit codes and error handling
- [ ] Test timeout behavior
### Task 3.2: Create integration tests for hooks
**Description**: Write integration tests that verify complete hook execution flow
**Size**: Large
**Priority**: High
**Dependencies**: Phase 2 complete
**Can run parallel with**: Task 3.1
**Integration Test Framework**:
```typescript
// tests/hooks/integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const HOOKS_BIN = path.join(__dirname, '../../dist/hooks-cli.js');
describe('claudekit-hooks integration', () => {
let testDir: string;
beforeEach(async () => {
// Create temp test directory
testDir = path.join(__dirname, `test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
describe('typecheck hook', () => {
it('should pass for valid TypeScript', async () => {
// Create test files
await fs.writeFile(path.join(testDir, 'tsconfig.json'), JSON.stringify({
compilerOptions: { strict: true }
}));
await fs.writeFile(path.join(testDir, 'test.ts'), `
const greeting: string = 'hello';
console.log(greeting);
`);
// Create payload
const payload = JSON.stringify({
tool_input: { file_path: path.join(testDir, 'test.ts') }
});
// Run hook
const exitCode = await runHook('typecheck', payload, testDir);
expect(exitCode).toBe(0);
});
it('should fail for TypeScript with any types', async () => {
await fs.writeFile(path.join(testDir, 'tsconfig.json'), JSON.stringify({
compilerOptions: { strict: true }
}));
await fs.writeFile(path.join(testDir, 'test.ts'), `
const data: any = { foo: 'bar' };
console.log(data);
`);
const payload = JSON.stringify({
tool_input: { file_path: path.join(testDir, 'test.ts') }
});
const exitCode = await runHook('no-any', payload, testDir);
expect(exitCode).toBe(2);
});
});
});
async function runHook(
hookName: string,
payload: string,
cwd: string,
configPath?: string
): Promise<number> {
return new Promise((resolve) => {
const args = [hookName];
if (configPath) {
args.push('--config', configPath);
}
const child = spawn('node', [HOOKS_BIN, ...args], {
cwd,
env: { ...process.env, NODE_ENV: 'test' }
});
// Send payload to stdin
child.stdin.write(payload);
child.stdin.end();
child.on('close', (code) => {
resolve(code || 0);
});
});
}
```
**Acceptance Criteria**:
- [ ] Test full hook execution with stdin/stdout
- [ ] Verify each hook's happy path
- [ ] Test error cases and exit codes
- [ ] Test configuration loading
- [ ] Verify Claude Code payload parsing
- [ ] Test with real file system operations
### Task 3.3: Create example configurations
**Description**: Create example .claudekit/config.json files demonstrating hook configuration
**Size**: Small
**Priority**: Medium
**Dependencies**: Phase 2 complete
**Can run parallel with**: Task 3.4
**Example Configuration**:
```json
{
"hooks": {
"typecheck": {
"command": "pnpm exec tsc --noEmit",
"timeout": 45000
},
"no-any": {
"timeout": 5000
},
"eslint": {
"command": "pnpm exec eslint",
"timeout": 30000
},
"run-related-tests": {
"command": "pnpm test",
"timeout": 60000,
"pattern": "**/*.{test,spec}.{js,ts}"
},
"auto-checkpoint": {
"timeout": 10000,
"prefix": "claude",
"maxCheckpoints": 10
},
"project-validation": {
"command": "pnpm run validate",
"timeout": 120000
},
"validate-todo-completion": {
"timeout": 5000
}
}
}
```
**Claude Settings Example**:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{"type": "command", "command": "claudekit-hooks typecheck"},
{"type": "command", "command": "claudekit-hooks no-any"}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [{"type": "command", "command": "claudekit-hooks eslint"}]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{"type": "command", "command": "claudekit-hooks auto-checkpoint"},
{"type": "command", "