diff --git a/bin/hooks/phase-tracker.js b/bin/hooks/phase-tracker.js new file mode 100644 index 0000000..58f7150 --- /dev/null +++ b/bin/hooks/phase-tracker.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Phase Tracker Hook (Cross-platform) + * + * Tracks current agentful phase from .agentful/state.json. + * Cross-platform compatible (Windows, macOS, Linux). + * + * Equivalent to: if [ -f .agentful/state.json ]; then jq -r '.current_phase // "idle"' .agentful/state.json 2>/dev/null || echo 'idle'; else echo 'idle'; fi + * + * Input: Reads tool_input JSON from stdin (Claude Code hook standard) + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const STATE_FILE = path.join(process.cwd(), '.agentful', 'state.json'); + +try { + if (fs.existsSync(STATE_FILE)) { + const content = fs.readFileSync(STATE_FILE, 'utf8'); + const state = JSON.parse(content); + const phase = state.current_phase || 'idle'; + // Silent output - just track phase internally + // console.log(phase); + } +} catch (error) { + // Silently fail - phase tracking is optional +} + +// Always exit 0 - phase tracking is informational +process.exit(0); diff --git a/bin/hooks/similar-doc-detector.js b/bin/hooks/similar-doc-detector.js new file mode 100644 index 0000000..54d35a8 --- /dev/null +++ b/bin/hooks/similar-doc-detector.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +/** + * Similar Documentation Detector (Cross-platform) + * + * Detects if similar markdown files already exist. + * Equivalent to the complex bash find/grep/sed command. + * + * Original bash: + * if [ "$FILE" = "*.md" ]; then + * existing=$(find . -name '*.md' -not -path './node_modules/*' -not -path './.git/*' | + * xargs grep -l "$(basename '$FILE' | sed 's/_/ /g' | sed 's/.md$//' | head -c 30)" 2>/dev/null | + * grep -v "$FILE" | head -1) + * if [ -n "$existing" ]; then + * echo "⚠️ Similar doc exists: $existing - consider updating instead" + * fi + * fi + * + * Input: Reads tool_input JSON from stdin (Claude Code hook standard) + */ + +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; + +const MAX_SEARCH_LENGTH = 30; + +// Read input from stdin (Claude Code hook standard) +async function readInput() { + return new Promise((resolve) => { + let input = ''; + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', (line) => { input += line; }); + rl.on('close', () => { + try { + resolve(JSON.parse(input)); + } catch { + resolve({}); + } + }); + }); +} + +// Main execution +(async () => { + const input = await readInput(); + const filePath = input?.tool_input?.file_path || input?.parameters?.file_path || ''; + + // Only check markdown files + if (!filePath.endsWith('.md') && !filePath.endsWith('.mdx')) { + process.exit(0); + } + + // Extract search term from filename + const basename = path.basename(filePath); + const nameWithoutExt = basename.replace(/\.mdx?$/, ''); + const searchTerm = nameWithoutExt.replace(/_/g, ' ').substring(0, MAX_SEARCH_LENGTH); + + if (!searchTerm.trim()) { + process.exit(0); + } + + // Recursively find all .md files, excluding node_modules and .git + function findMarkdownFiles(dir, files = []) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.name === 'node_modules') continue; + + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + findMarkdownFiles(fullPath, files); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) { + files.push(fullPath); + } + } + } catch { + // Ignore permission errors + } + return files; + } + + // Check if file contains search term + function fileContainsSearchTerm(file, term) { + try { + const content = fs.readFileSync(file, 'utf8'); + return content.toLowerCase().includes(term.toLowerCase()); + } catch { + return false; + } + } + + // Find similar files + const allMdFiles = findMarkdownFiles(process.cwd()); + const normalizedTargetPath = path.normalize(filePath); + + for (const file of allMdFiles) { + const normalizedFile = path.normalize(file); + + // Skip the file being edited + if (normalizedFile === normalizedTargetPath) continue; + + // Check if this file contains the search term + if (fileContainsSearchTerm(file, searchTerm)) { + const relativePath = path.relative(process.cwd(), file); + console.log(`⚠️ Similar doc exists: ${relativePath} - consider updating instead`); + break; // Only report first match + } + } + + process.exit(0); +})(); diff --git a/bin/hooks/typecheck-limiter.js b/bin/hooks/typecheck-limiter.js new file mode 100644 index 0000000..0807733 --- /dev/null +++ b/bin/hooks/typecheck-limiter.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/** + * TypeScript Check Hook (Cross-platform) + * + * Runs tsc --noEmit and shows first 5 lines of output. + * Equivalent to: npx tsc --noEmit 2>&1 | head -5 || true + */ + +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +const MAX_LINES = 5; + +// Find tsc - try local node_modules first, then global +function findTsc() { + const localTsc = path.join(process.cwd(), 'node_modules', '.bin', 'tsc'); + if (fs.existsSync(localTsc + '.cmd') || fs.existsSync(localTsc)) { + return localTsc; + } + return 'tsc'; +} + +const tscPath = findTsc(); +const tsc = spawn(tscPath, ['--noEmit'], { + shell: true, + cwd: process.cwd() +}); + +let output = ''; +let lineCount = 0; + +tsc.stdout.on('data', (data) => { + if (lineCount < MAX_LINES) { + const lines = data.toString().split('\n'); + for (const line of lines) { + if (lineCount < MAX_LINES && line.trim()) { + output += line + '\n'; + lineCount++; + } + } + } +}); + +tsc.stderr.on('data', (data) => { + if (lineCount < MAX_LINES) { + const lines = data.toString().split('\n'); + for (const line of lines) { + if (lineCount < MAX_LINES && line.trim()) { + output += line + '\n'; + lineCount++; + } + } + } +}); + +tsc.on('close', () => { + if (output.trim()) { + console.log(output.trim()); + } + // Always exit 0 - type errors are informational + process.exit(0); +}); + +tsc.on('error', () => { + // tsc not available - exit silently + process.exit(0); +}); diff --git a/template/.claude/settings.json b/template/.claude/settings.json index 048fbe6..181e150 100644 --- a/template/.claude/settings.json +++ b/template/.claude/settings.json @@ -108,7 +108,9 @@ "hooks": [ { "type": "command", - "command": "npx tsc --noEmit 2>&1 | head -5 || true" + "command": "node bin/hooks/typecheck-limiter.js", + "timeout": 30, + "description": "Run TypeScript type check and show first 5 errors (cross-platform)" } ] }, @@ -117,7 +119,9 @@ "hooks": [ { "type": "command", - "command": "if [ \"$FILE\" = \"*.md\" ]; then existing=$(find . -name '*.md' -not -path './node_modules/*' -not -path './.git/*' | xargs grep -l \"$(basename '$FILE' | sed 's/_/ /g' | sed 's/.md$//' | head -c 30)\" 2>/dev/null | grep -v \"$FILE\" | head -1); if [ -n \"$existing\" ]; then echo \"\u26a0\ufe0f Similar doc exists: $existing - consider updating instead\"; fi; fi || true" + "command": "node bin/hooks/similar-doc-detector.js", + "timeout": 5, + "description": "Warn if similar markdown documentation already exists (cross-platform)" } ] } @@ -127,7 +131,8 @@ "hooks": [ { "type": "command", - "command": "if [ -f .agentful/state.json ]; then jq -r '.current_phase // \"idle\"' .agentful/state.json 2>/dev/null || echo 'idle'; else echo 'idle'; fi", + "command": "node bin/hooks/phase-tracker.js", + "timeout": 3, "description": "Track current agentful phase" } ]