diff --git a/public/admin/dashboard.html b/public/admin/dashboard.html index 540b60a0..0ff10eb5 100644 --- a/public/admin/dashboard.html +++ b/public/admin/dashboard.html @@ -30,6 +30,7 @@ šŸ”§ Rule Manager Blog Curation šŸ“Š Audit Analytics + šŸ”’ Hooks Dashboard
diff --git a/public/admin/hooks-dashboard.html b/public/admin/hooks-dashboard.html new file mode 100644 index 00000000..60c85b0a --- /dev/null +++ b/public/admin/hooks-dashboard.html @@ -0,0 +1,202 @@ + + + + + + Framework Hooks Dashboard | Tractatus Admin + + + + + + + + + +
+ + +
+

Framework Enforcement Metrics

+

Real-time monitoring of Claude Code hook validators and architectural enforcement

+
+ + +
+ +
+
+
+ +
+
+

Total Hook Executions

+

-

+
+
+
+ + +
+
+
+ +
+
+

Operations Blocked

+

-

+
+
+
+ + +
+
+
+ +
+
+

Block Rate

+

-

+
+
+
+ + +
+
+
+ +
+
+

Last Activity

+

-

+
+
+
+
+ + +
+ +
+
+

+ + + + Edit Hook +

+
+
+
+
+ Total Executions: + - +
+
+ Blocks: + - +
+
+ Success Rate: + - +
+
+
+
+ + +
+
+

+ + + + Write Hook +

+
+
+
+
+ Total Executions: + - +
+
+ Blocks: + - +
+
+ Success Rate: + - +
+
+
+
+
+ + +
+
+

Recent Blocked Operations

+
+
+
+
No blocked operations
+
+
+
+ + +
+
+

Recent Hook Executions

+ +
+
+
+
Loading activity...
+
+
+
+ +
+ + + + + diff --git a/public/js/admin/hooks-dashboard.js b/public/js/admin/hooks-dashboard.js new file mode 100644 index 00000000..d5e25ceb --- /dev/null +++ b/public/js/admin/hooks-dashboard.js @@ -0,0 +1,209 @@ +/** + * Hooks Dashboard + * Real-time monitoring of framework enforcement hooks + */ + +const API_BASE = window.location.hostname === 'localhost' ? 'http://localhost:9000/api' : '/api'; + +// Load metrics on page load +document.addEventListener('DOMContentLoaded', () => { + loadMetrics(); + + // Auto-refresh every 30 seconds + setInterval(loadMetrics, 30000); + + // Manual refresh button + document.getElementById('refresh-btn')?.addEventListener('click', loadMetrics); +}); + +/** + * Load hook metrics + */ +async function loadMetrics() { + try { + const response = await fetch(`${API_BASE}/admin/hooks/metrics`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('tractatus_admin_token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to load metrics'); + } + + const data = await response.json(); + displayMetrics(data); + } catch (error) { + console.error('Error loading metrics:', error); + showError('Failed to load hook metrics'); + } +} + +/** + * Display metrics in UI + */ +function displayMetrics(data) { + const metrics = data.metrics || {}; + const executions = metrics.hook_executions || []; + const blocks = metrics.blocks || []; + const stats = metrics.session_stats || {}; + + // Calculate totals + const totalExecutions = executions.length; + const totalBlocks = blocks.length; + const blockRate = totalExecutions > 0 ? ((totalBlocks / totalExecutions) * 100).toFixed(1) + '%' : '0%'; + + // Update quick stats + document.getElementById('stat-total-executions').textContent = totalExecutions.toLocaleString(); + document.getElementById('stat-total-blocks').textContent = totalBlocks.toLocaleString(); + document.getElementById('stat-block-rate').textContent = blockRate; + + // Last updated + const lastUpdated = stats.last_updated ? formatRelativeTime(new Date(stats.last_updated)) : 'Never'; + document.getElementById('stat-last-updated').textContent = lastUpdated; + + // Hook breakdown + const editExecutions = executions.filter(e => e.hook === 'validate-file-edit').length; + const editBlocks = blocks.filter(b => b.hook === 'validate-file-edit').length; + const editSuccessRate = editExecutions > 0 ? (((editExecutions - editBlocks) / editExecutions) * 100).toFixed(1) + '%' : '100%'; + + document.getElementById('edit-executions').textContent = editExecutions.toLocaleString(); + document.getElementById('edit-blocks').textContent = editBlocks.toLocaleString(); + document.getElementById('edit-success-rate').textContent = editSuccessRate; + + const writeExecutions = executions.filter(e => e.hook === 'validate-file-write').length; + const writeBlocks = blocks.filter(b => b.hook === 'validate-file-write').length; + const writeSuccessRate = writeExecutions > 0 ? (((writeExecutions - writeBlocks) / writeExecutions) * 100).toFixed(1) + '%' : '100%'; + + document.getElementById('write-executions').textContent = writeExecutions.toLocaleString(); + document.getElementById('write-blocks').textContent = writeBlocks.toLocaleString(); + document.getElementById('write-success-rate').textContent = writeSuccessRate; + + // Recent blocks + displayRecentBlocks(blocks.slice(-10).reverse()); + + // Recent activity + displayRecentActivity(executions.slice(-20).reverse()); +} + +/** + * Display recent blocked operations + */ +function displayRecentBlocks(blocks) { + const container = document.getElementById('recent-blocks'); + + if (blocks.length === 0) { + container.innerHTML = '
No blocked operations
'; + return; + } + + const html = blocks.map(block => ` +
+
+
+ + + +
+
+
+ ${block.hook.replace('validate-file-', '')} + ${formatRelativeTime(new Date(block.timestamp))} +
+

${escapeHtml(block.file)}

+

${escapeHtml(block.reason)}

+
+
+
+ `).join(''); + + container.innerHTML = html; +} + +/** + * Display recent hook executions + */ +function displayRecentActivity(executions) { + const container = document.getElementById('recent-activity'); + + if (executions.length === 0) { + container.innerHTML = '
No recent activity
'; + return; + } + + const html = executions.map(exec => ` +
+
+
+ ${exec.result === 'passed' ? ` + + + + ` : ` + + + + `} +
+
+
+ ${exec.hook.replace('validate-file-', '')} + ${formatRelativeTime(new Date(exec.timestamp))} +
+

${escapeHtml(exec.file)}

+ ${exec.reason ? `

${escapeHtml(exec.reason)}

` : ''} +
+
+
+ `).join(''); + + container.innerHTML = html; +} + +/** + * Format relative time + */ +function formatRelativeTime(date) { + const now = new Date(); + const diffMs = now - date; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) { + return 'Just now'; + } else if (diffMin < 60) { + return `${diffMin}m ago`; + } else if (diffHour < 24) { + return `${diffHour}h ago`; + } else if (diffDay < 7) { + return `${diffDay}d ago`; + } else { + return date.toLocaleDateString(); + } +} + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Show error message + */ +function showError(message) { + const container = document.getElementById('recent-activity'); + container.innerHTML = ` +
+

${escapeHtml(message)}

+ +
+ `; +} diff --git a/scripts/hook-validators/validate-file-edit.js b/scripts/hook-validators/validate-file-edit.js index 416e45e5..6e16368a 100755 --- a/scripts/hook-validators/validate-file-edit.js +++ b/scripts/hook-validators/validate-file-edit.js @@ -23,7 +23,10 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const FILE_PATH = process.argv[2]; +// Hooks receive input via stdin as JSON +let HOOK_INPUT = null; +let FILE_PATH = null; + const SESSION_STATE_PATH = path.join(__dirname, '../../.claude/session-state.json'); const INSTRUCTION_HISTORY_PATH = path.join(__dirname, '../../.claude/instruction-history.json'); @@ -177,15 +180,99 @@ function updateSessionState() { } } +/** + * Log metrics for hook execution + */ +function logMetrics(result, reason = null) { + try { + const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json'); + const METRICS_DIR = path.dirname(METRICS_PATH); + + // Ensure directory exists + if (!fs.existsSync(METRICS_DIR)) { + fs.mkdirSync(METRICS_DIR, { recursive: true }); + } + + // Load existing metrics + let metrics = { hook_executions: [], blocks: [], session_stats: {} }; + if (fs.existsSync(METRICS_PATH)) { + metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8')); + } + + // Log execution + metrics.hook_executions.push({ + hook: 'validate-file-edit', + timestamp: new Date().toISOString(), + file: FILE_PATH, + result: result, + reason: reason + }); + + // Log block if failed + if (result === 'blocked') { + metrics.blocks.push({ + hook: 'validate-file-edit', + timestamp: new Date().toISOString(), + file: FILE_PATH, + reason: reason + }); + } + + // Update session stats + metrics.session_stats.total_edit_hooks = (metrics.session_stats.total_edit_hooks || 0) + 1; + metrics.session_stats.total_edit_blocks = (metrics.session_stats.total_edit_blocks || 0) + (result === 'blocked' ? 1 : 0); + metrics.session_stats.last_updated = new Date().toISOString(); + + // Keep only last 1000 executions + if (metrics.hook_executions.length > 1000) { + metrics.hook_executions = metrics.hook_executions.slice(-1000); + } + + // Keep only last 500 blocks + if (metrics.blocks.length > 500) { + metrics.blocks = metrics.blocks.slice(-500); + } + + // Write metrics + fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2)); + } catch (err) { + // Non-critical - don't fail on metrics logging + } +} + +/** + * Read hook input from stdin + */ +function readHookInput() { + return new Promise((resolve) => { + let data = ''; + process.stdin.on('data', chunk => { + data += chunk; + }); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + resolve(null); + } + }); + }); +} + /** * Main validation */ async function main() { - if (!FILE_PATH) { - error('No file path provided'); - process.exit(1); + // Read input from stdin + HOOK_INPUT = await readHookInput(); + + if (!HOOK_INPUT || !HOOK_INPUT.tool_input || !HOOK_INPUT.tool_input.file_path) { + error('No file path provided in hook input'); + logMetrics('error', 'No file path in input'); + process.exit(2); // Exit code 2 = BLOCK } + FILE_PATH = HOOK_INPUT.tool_input.file_path; log(`\nšŸ” Hook: Validating file edit: ${FILE_PATH}`, 'cyan'); // Check 1: Pre-action validation @@ -195,7 +282,8 @@ async function main() { if (preCheck.output) { console.log(preCheck.output); } - process.exit(1); + logMetrics('blocked', preCheck.reason); + process.exit(2); // Exit code 2 = BLOCK } success('Pre-action check passed'); @@ -206,7 +294,8 @@ async function main() { conflicts.conflicts.forEach(c => { log(` • ${c.id}: ${c.instruction} [${c.quadrant}]`, 'yellow'); }); - process.exit(1); + logMetrics('blocked', conflicts.reason); + process.exit(2); // Exit code 2 = BLOCK } success('No instruction conflicts detected'); @@ -214,18 +303,23 @@ async function main() { const boundary = checkBoundaryViolation(); if (!boundary.passed) { error(boundary.reason); - process.exit(1); + logMetrics('blocked', boundary.reason); + process.exit(2); // Exit code 2 = BLOCK } success('No boundary violations detected'); // Update session state updateSessionState(); + // Log successful execution + logMetrics('passed'); + success('File edit validation complete\n'); process.exit(0); } main().catch(err => { error(`Hook validation error: ${err.message}`); - process.exit(1); + logMetrics('error', err.message); + process.exit(2); // Exit code 2 = BLOCK }); diff --git a/scripts/hook-validators/validate-file-write.js b/scripts/hook-validators/validate-file-write.js index a00eb151..68273788 100755 --- a/scripts/hook-validators/validate-file-write.js +++ b/scripts/hook-validators/validate-file-write.js @@ -24,7 +24,10 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const FILE_PATH = process.argv[2]; +// Hooks receive input via stdin as JSON +let HOOK_INPUT = null; +let FILE_PATH = null; + const SESSION_STATE_PATH = path.join(__dirname, '../../.claude/session-state.json'); const INSTRUCTION_HISTORY_PATH = path.join(__dirname, '../../.claude/instruction-history.json'); @@ -195,12 +198,36 @@ function updateSessionState() { /** * Main validation */ +/** + * Read hook input from stdin + */ +function readHookInput() { + return new Promise((resolve) => { + let data = ""; + process.stdin.on("data", chunk => { + data += chunk; + }); + process.stdin.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + resolve(null); + } + }); + }); +} + async function main() { - if (!FILE_PATH) { - error('No file path provided'); - process.exit(1); + // Read input from stdin + HOOK_INPUT = await readHookInput(); + + if (!HOOK_INPUT || !HOOK_INPUT.tool_input || !HOOK_INPUT.tool_input.file_path) { + logMetrics('error', 'No file path in input'); + error('No file path provided in hook input'); + process.exit(2); // Exit code 2 = BLOCK } + FILE_PATH = HOOK_INPUT.tool_input.file_path; log(`\nšŸ” Hook: Validating file write: ${FILE_PATH}`, 'cyan'); // Check 1: Pre-action validation @@ -210,7 +237,8 @@ async function main() { if (preCheck.output) { console.log(preCheck.output); } - process.exit(1); + logMetrics('blocked', preCheck.reason); + process.exit(2); // Exit code 2 = BLOCK } success('Pre-action check passed'); @@ -218,7 +246,8 @@ async function main() { const overwriteCheck = checkOverwriteWithoutRead(); if (!overwriteCheck.passed) { error(overwriteCheck.reason); - process.exit(1); + logMetrics('blocked', overwriteCheck.reason); + process.exit(2); // Exit code 2 = BLOCK } // Check 3: CrossReferenceValidator @@ -228,7 +257,8 @@ async function main() { conflicts.conflicts.forEach(c => { log(` • ${c.id}: ${c.instruction} [${c.quadrant}]`, 'yellow'); }); - process.exit(1); + logMetrics('blocked', conflicts.reason); + process.exit(2); // Exit code 2 = BLOCK } success('No instruction conflicts detected'); @@ -236,18 +266,67 @@ async function main() { const boundary = checkBoundaryViolation(); if (!boundary.passed) { error(boundary.reason); - process.exit(1); + logMetrics('blocked', boundary.reason); + process.exit(2); // Exit code 2 = BLOCK } success('No boundary violations detected'); // Update session state updateSessionState(); + // Log successful execution + logMetrics('passed'); + success('File write validation complete\n'); process.exit(0); } main().catch(err => { error(`Hook validation error: ${err.message}`); - process.exit(1); + logMetrics('error', err.message); + process.exit(2); // Exit code 2 = BLOCK }); + +/** + * Log metrics for hook execution + */ +function logMetrics(result, reason = null) { + try { + const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json'); + const METRICS_DIR = path.dirname(METRICS_PATH); + if (!fs.existsSync(METRICS_DIR)) { + fs.mkdirSync(METRICS_DIR, { recursive: true }); + } + let metrics = { hook_executions: [], blocks: [], session_stats: {} }; + if (fs.existsSync(METRICS_PATH)) { + metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8')); + } + metrics.hook_executions.push({ + hook: 'validate-file-write', + timestamp: new Date().toISOString(), + file: FILE_PATH, + result: result, + reason: reason + }); + if (result === 'blocked') { + metrics.blocks.push({ + hook: 'validate-file-write', + timestamp: new Date().toISOString(), + file: FILE_PATH, + reason: reason + }); + } + metrics.session_stats.total_write_hooks = (metrics.session_stats.total_write_hooks || 0) + 1; + metrics.session_stats.total_write_blocks = (metrics.session_stats.total_write_blocks || 0) + (result === 'blocked' ? 1 : 0); + metrics.session_stats.last_updated = new Date().toISOString(); + if (metrics.hook_executions.length > 1000) { + metrics.hook_executions = metrics.hook_executions.slice(-1000); + } + if (metrics.blocks.length > 500) { + metrics.blocks = metrics.blocks.slice(-500); + } + fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2)); + } catch (err) { + // Non-critical + } +} diff --git a/src/routes/hooks-metrics.routes.js b/src/routes/hooks-metrics.routes.js new file mode 100644 index 00000000..acc3325b --- /dev/null +++ b/src/routes/hooks-metrics.routes.js @@ -0,0 +1,49 @@ +/** + * Hooks Metrics API Routes + * Serves framework enforcement metrics to admin dashboard + */ + +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const { authMiddleware, roleMiddleware } = require('../middleware/auth.middleware'); + +const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json'); + +/** + * GET /api/admin/hooks/metrics + * Get current hooks metrics + */ +router.get('/metrics', authMiddleware, roleMiddleware(['admin']), async (req, res) => { + try { + // Check if metrics file exists + if (!fs.existsSync(METRICS_PATH)) { + return res.json({ + success: true, + metrics: { + hook_executions: [], + blocks: [], + session_stats: {} + } + }); + } + + // Read metrics + const metricsData = fs.readFileSync(METRICS_PATH, 'utf8'); + const metrics = JSON.parse(metricsData); + + res.json({ + success: true, + metrics: metrics + }); + } catch (error) { + console.error('Error fetching hooks metrics:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch hooks metrics' + }); + } +}); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index 62cfd51b..3c261723 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -14,6 +14,7 @@ const newsletterRoutes = require('./newsletter.routes'); const mediaRoutes = require('./media.routes'); const casesRoutes = require('./cases.routes'); const adminRoutes = require('./admin.routes'); +const hooksMetricsRoutes = require('./hooks-metrics.routes'); const rulesRoutes = require('./rules.routes'); const projectsRoutes = require('./projects.routes'); const auditRoutes = require('./audit.routes'); @@ -35,6 +36,7 @@ router.use('/newsletter', newsletterRoutes); router.use('/media', mediaRoutes); router.use('/cases', casesRoutes); router.use('/admin', adminRoutes); +router.use('/admin/hooks', hooksMetricsRoutes); router.use('/admin/rules', rulesRoutes); router.use('/admin/projects', projectsRoutes); router.use('/admin', auditRoutes);