diff --git a/.claude/hooks/plan-detection-hook.js b/.claude/hooks/plan-detection-hook.js new file mode 100755 index 00000000..346eb648 --- /dev/null +++ b/.claude/hooks/plan-detection-hook.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +/** + * Plan Detection Hook (UserPromptSubmit) + * + * Detects planning discussions in user prompts and tracks them. + * Part of the enforcement mechanism for inst_comm_plan_001. + * + * INCIDENT REFERENCE: 2025-12-01 lost plan documentation + * + * When a user initiates a planning discussion: + * 1. Detect planning keywords + * 2. Track the plan as in-progress + * 3. Inject a reminder to Claude about persisting plans + * + * Hook Input (JSON via stdin): + * { + * "session_id": "abc123", + * "hook_event_name": "UserPromptSubmit", + * "user_message": "Let's create a plan for the landing page redesign" + * } + * + * Hook Output (JSON to stdout): + * { + * "hookSpecificOutput": { ... }, + * "systemMessage": "Planning discussion detected...", + * "continue": true + * } + */ + +const path = require('path'); + +// Import the plan persistence manager +const planManager = require('./plan-persistence-manager.js'); + +/** + * Read JSON input from stdin + */ +function readStdin() { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error('Invalid JSON input')); + } + }); + process.stdin.on('error', reject); + }); +} + +/** + * Output hook response + */ +function outputResponse(analysis, systemMessage = null) { + const response = { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + planDetection: analysis + }, + continue: true, + suppressOutput: false + }; + + if (systemMessage) { + response.systemMessage = systemMessage; + } + + console.log(JSON.stringify(response)); +} + +/** + * Main hook logic + */ +async function main() { + let input; + + try { + input = await readStdin(); + } catch (err) { + // Invalid input, allow execution + outputResponse({ error: 'Invalid hook input' }); + process.exit(0); + } + + const { session_id, user_message } = input; + + if (!user_message) { + outputResponse({ detected: false }); + process.exit(0); + } + + // Clean up old plans periodically + planManager.cleanupOldPlans(); + + // Detect if this is a planning discussion + const detection = planManager.detectPlanningDiscussion(user_message); + + if (!detection.isPlanningDiscussion) { + // Not a planning discussion, check for unpersisted plans + const unpersisted = planManager.checkUnpersistedPlans(session_id); + + if (unpersisted.hasUnpersistedPlans && unpersisted.hasHighPriority) { + // High priority unpersisted plans - CRITICAL warning + outputResponse( + { detected: false, unpersistedCheck: unpersisted }, + unpersisted.warningMessage + ); + } else { + outputResponse({ detected: false }); + } + + process.exit(0); + } + + // Track the plan in progress + const trackedPlan = planManager.trackPlanInProgress( + session_id, + detection.topic, + detection.confidence, + detection.isHighPriority + ); + + // Build system message + let systemMessage; + + if (detection.isHighPriority) { + systemMessage = + `🚨 PLAN PERSISTENCE REQUIRED (inst_comm_plan_001)\n\n` + + `User has EXPLICITLY requested plan documentation.\n` + + `Topic: "${detection.topic}"\n\n` + + `BEFORE this session ends, you MUST:\n` + + `1. Create a comprehensive plan document\n` + + `2. Save it to: docs/plans/PLAN_[TOPIC]_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.md\n` + + `3. This is MANDATORY - do NOT rely on internal memory\n\n` + + `āš ļø INCIDENT 2025-12-01: User's plan was lost because Claude kept it in memory. Never again.`; + } else if (detection.confidence >= 0.7) { + systemMessage = + `šŸ“‹ Planning Discussion Detected (${Math.round(detection.confidence * 100)}% confidence)\n\n` + + `Topic: "${detection.topic}"\n\n` + + `Per inst_comm_plan_001: If this discussion produces a significant plan, ` + + `save it to docs/plans/ directory before session ends.\n\n` + + `Recommended file: docs/plans/PLAN_[TOPIC]_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.md`; + } else { + // Lower confidence - just note it + systemMessage = + `šŸ“ Possible planning discussion detected.\n` + + `If significant plans emerge, save to docs/plans/.`; + } + + outputResponse({ + detected: true, + topic: detection.topic, + confidence: detection.confidence, + isHighPriority: detection.isHighPriority, + tracked: true + }, systemMessage); + + process.exit(0); +} + +// Run hook +main().catch(err => { + console.error(JSON.stringify({ + hookSpecificOutput: { error: err.message }, + continue: true, + suppressOutput: false + })); + process.exit(0); +}); diff --git a/.claude/hooks/plan-persistence-checker.js b/.claude/hooks/plan-persistence-checker.js new file mode 100755 index 00000000..39de5d3c --- /dev/null +++ b/.claude/hooks/plan-persistence-checker.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * Plan Persistence Checker Hook (PreToolUse) + * + * Runs before tool execution to: + * 1. Check for unpersisted plans and remind Claude + * 2. Detect when plans are being written and mark them as persisted + * + * Part of the enforcement mechanism for inst_comm_plan_001. + * + * INCIDENT REFERENCE: 2025-12-01 lost plan documentation + * + * Hook Input (JSON via stdin): + * { + * "session_id": "abc123", + * "hook_event_name": "PreToolUse", + * "tool_name": "Write", + * "tool_input": { "file_path": "/path/to/file" } + * } + */ + +const path = require('path'); +const fs = require('fs'); + +// Import the plan persistence manager +const planManager = require('./plan-persistence-manager.js'); + +/** + * Read JSON input from stdin + */ +function readStdin() { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error('Invalid JSON input')); + } + }); + process.stdin.on('error', reject); + }); +} + +/** + * Output hook response + */ +function outputResponse(decision, reason, systemMessage = null) { + const response = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: reason + }, + continue: true, + suppressOutput: false + }; + + if (systemMessage) { + response.systemMessage = systemMessage; + } + + console.log(JSON.stringify(response)); +} + +/** + * Main hook logic + */ +async function main() { + let input; + + try { + input = await readStdin(); + } catch (err) { + outputResponse('allow', 'Invalid hook input'); + process.exit(0); + } + + const { session_id, tool_name, tool_input } = input; + + // Only check Write and Edit tools + if (!['Write', 'Edit'].includes(tool_name)) { + outputResponse('allow', 'Non-file tool, no plan check needed'); + process.exit(0); + } + + const filePath = tool_input.file_path || tool_input.path || ''; + + // Check if this is writing to docs/plans/ (plan being persisted) + if (filePath.includes('docs/plans/') && filePath.endsWith('.md')) { + const persistenceResult = planManager.checkPlanPersistence(filePath, session_id); + + if (persistenceResult.markedAsPersisted) { + outputResponse( + 'allow', + 'Plan persistence detected', + `āœ… PLAN PERSISTENCE: ${persistenceResult.message}\n\n` + + `${persistenceResult.plansUpdated} plan(s) marked as persisted. Good work following inst_comm_plan_001.` + ); + process.exit(0); + } + + // New plan file being created + outputResponse( + 'allow', + 'Writing new plan file', + `šŸ“„ Creating plan file: ${path.basename(filePath)}\n` + + `This will be tracked as a persisted plan.` + ); + process.exit(0); + } + + // Check for unpersisted plans + const unpersisted = planManager.checkUnpersistedPlans(session_id); + + if (unpersisted.hasUnpersistedPlans) { + // Get stats for context + const stats = planManager.getSessionStats(session_id); + + if (unpersisted.hasHighPriority) { + // HIGH PRIORITY: User explicitly requested plan documentation + // Don't block, but inject a strong reminder + outputResponse( + 'allow', + 'Unpersisted high-priority plan detected', + `🚨 CRITICAL REMINDER (inst_comm_plan_001)\n\n` + + `${unpersisted.warningMessage}\n\n` + + `Session Stats: ${stats.persisted}/${stats.totalPlans} plans persisted\n` + + `\nšŸ“Š This check runs on every file operation to ensure plans are not lost.` + ); + } else if (unpersisted.plans.length > 0) { + // Regular unpersisted plans - gentle reminder + outputResponse( + 'allow', + 'Unpersisted plans detected', + unpersisted.warningMessage + ); + } else { + outputResponse('allow', 'No plan concerns'); + } + } else { + outputResponse('allow', 'No unpersisted plans'); + } + + process.exit(0); +} + +// Run hook +main().catch(err => { + // Don't block on errors + outputResponse('allow', `Hook error: ${err.message}`); + process.exit(0); +}); diff --git a/.claude/hooks/plan-persistence-manager.js b/.claude/hooks/plan-persistence-manager.js new file mode 100755 index 00000000..d06e13c2 --- /dev/null +++ b/.claude/hooks/plan-persistence-manager.js @@ -0,0 +1,428 @@ +#!/usr/bin/env node + +/** + * Plan Persistence Manager + * + * Tracks planning discussions and ensures plans are persisted to MD files. + * Part of the enforcement mechanism for inst_comm_plan_001. + * + * INCIDENT REFERENCE: 2025-12-01 lost plan documentation + * + * Three functions: + * 1. detectPlanningDiscussion(userMessage) - Detect if user is starting a plan + * 2. trackPlanInProgress(sessionId, planTopic) - Mark a plan as in progress + * 3. checkUnpersistedPlans(sessionId) - Check for plans not yet saved to MD + * 4. markPlanPersisted(sessionId, planTopic, filePath) - Mark plan as saved + */ + +const fs = require('fs'); +const path = require('path'); + +// Session state file for tracking plans +const SESSION_STATE_DIR = path.join(__dirname, '..', 'session-state'); +const PLANS_STATE_FILE = path.join(SESSION_STATE_DIR, 'plans-in-progress.json'); + +// Planning keywords that indicate a planning discussion +const PLANNING_KEYWORDS = [ + // Direct plan requests + 'create a plan', + 'make a plan', + 'write a plan', + 'document the plan', + 'save the plan', + 'outline the plan', + 'plan for', + 'planning', + + // Strategy discussions + 'strategy', + 'roadmap', + 'approach', + 'how should we', + 'let\'s discuss', + 'let\'s figure out', + 'design the', + 'architect', + + // Implementation discussions + 'implement', + 'implementation plan', + 'step by step', + 'phases', + 'milestones', + + // Feature discussions + 'redesign', + 'new feature', + 'feature request', + 'requirements', + 'spec', + 'specification', + + // Decision discussions + 'decision', + 'decide', + 'trade-offs', + 'options', + 'alternatives', + 'pros and cons', + + // Explicit requests to document + 'document this', + 'write this up', + 'save this', + 'record this', + 'keep a record', + 'md file', + 'markdown file' +]; + +// High-priority keywords that ALWAYS trigger plan tracking +const HIGH_PRIORITY_KEYWORDS = [ + 'document the plan', + 'save the plan', + 'write a plan', + 'md file', + 'markdown file', + 'keep a record', + 'document this discussion' +]; + +/** + * Ensure session state directory exists + */ +function ensureStateDir() { + if (!fs.existsSync(SESSION_STATE_DIR)) { + fs.mkdirSync(SESSION_STATE_DIR, { recursive: true }); + } +} + +/** + * Load current plans state + */ +function loadPlansState() { + ensureStateDir(); + + if (fs.existsSync(PLANS_STATE_FILE)) { + try { + return JSON.parse(fs.readFileSync(PLANS_STATE_FILE, 'utf-8')); + } catch { + return { plans: [], lastUpdated: null }; + } + } + + return { plans: [], lastUpdated: null }; +} + +/** + * Save plans state + */ +function savePlansState(state) { + ensureStateDir(); + state.lastUpdated = new Date().toISOString(); + fs.writeFileSync(PLANS_STATE_FILE, JSON.stringify(state, null, 2)); +} + +/** + * Detect if a message indicates a planning discussion + * + * @param {string} userMessage - The user's message + * @returns {Object} - { isPlanningDiscussion, confidence, topic, isHighPriority } + */ +function detectPlanningDiscussion(userMessage) { + if (!userMessage || typeof userMessage !== 'string') { + return { isPlanningDiscussion: false, confidence: 0, topic: null, isHighPriority: false }; + } + + const lowerMessage = userMessage.toLowerCase(); + + // Check high-priority keywords first + const isHighPriority = HIGH_PRIORITY_KEYWORDS.some(keyword => + lowerMessage.includes(keyword) + ); + + if (isHighPriority) { + // Extract topic from message (first 50 chars after keyword) + const topic = extractTopic(userMessage); + return { + isPlanningDiscussion: true, + confidence: 0.95, + topic, + isHighPriority: true + }; + } + + // Check regular planning keywords + const matchedKeywords = PLANNING_KEYWORDS.filter(keyword => + lowerMessage.includes(keyword) + ); + + if (matchedKeywords.length >= 2) { + // Multiple keyword matches = high confidence + return { + isPlanningDiscussion: true, + confidence: 0.85, + topic: extractTopic(userMessage), + isHighPriority: false + }; + } + + if (matchedKeywords.length === 1) { + // Single keyword match = medium confidence + return { + isPlanningDiscussion: true, + confidence: 0.6, + topic: extractTopic(userMessage), + isHighPriority: false + }; + } + + return { isPlanningDiscussion: false, confidence: 0, topic: null, isHighPriority: false }; +} + +/** + * Extract a topic from a message + */ +function extractTopic(message) { + // Take first 100 chars, remove special characters, truncate to first sentence + let topic = message.substring(0, 100) + .replace(/[^\w\s-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + // Find first sentence boundary + const sentenceEnd = topic.search(/[.!?]/); + if (sentenceEnd > 10) { + topic = topic.substring(0, sentenceEnd); + } + + return topic || 'Unnamed Plan'; +} + +/** + * Track a plan as in progress + * + * @param {string} sessionId - Current session ID + * @param {string} topic - Plan topic + * @param {number} confidence - Detection confidence + * @param {boolean} isHighPriority - Whether this is a high-priority plan request + */ +function trackPlanInProgress(sessionId, topic, confidence = 0.8, isHighPriority = false) { + const state = loadPlansState(); + + // Check if plan already tracked + const existingPlan = state.plans.find(p => + p.sessionId === sessionId && p.topic === topic + ); + + if (existingPlan) { + // Update existing plan + existingPlan.lastMentioned = new Date().toISOString(); + existingPlan.mentionCount = (existingPlan.mentionCount || 1) + 1; + if (isHighPriority && !existingPlan.isHighPriority) { + existingPlan.isHighPriority = true; + } + } else { + // Add new plan + state.plans.push({ + sessionId, + topic, + confidence, + isHighPriority, + startedAt: new Date().toISOString(), + lastMentioned: new Date().toISOString(), + mentionCount: 1, + persisted: false, + persistedTo: null + }); + } + + savePlansState(state); + + return state.plans.find(p => p.sessionId === sessionId && p.topic === topic); +} + +/** + * Check for unpersisted plans in current session + * + * @param {string} sessionId - Current session ID + * @returns {Object} - { hasUnpersistedPlans, plans, warningMessage } + */ +function checkUnpersistedPlans(sessionId) { + const state = loadPlansState(); + + const unpersistedPlans = state.plans.filter(p => + p.sessionId === sessionId && !p.persisted + ); + + if (unpersistedPlans.length === 0) { + return { hasUnpersistedPlans: false, plans: [], warningMessage: null }; + } + + // Check if any are high priority + const highPriorityPlans = unpersistedPlans.filter(p => p.isHighPriority); + + let warningMessage; + + if (highPriorityPlans.length > 0) { + warningMessage = `āš ļø CRITICAL: User explicitly requested plan documentation that has NOT been saved:\n` + + highPriorityPlans.map(p => ` • "${p.topic}" (requested ${p.mentionCount} times)`).join('\n') + + `\n\n🚨 INSTRUCTION inst_comm_plan_001 REQUIRES: Save plans to docs/plans/PLAN_[TOPIC]_[YYYYMMDD].md IMMEDIATELY.` + + `\n DO NOT proceed with other work until this is done.`; + } else { + warningMessage = `šŸ“‹ REMINDER: ${unpersistedPlans.length} plan(s) in progress may need to be saved:\n` + + unpersistedPlans.map(p => ` • "${p.topic}"`).join('\n') + + `\n\n Per inst_comm_plan_001: Save significant plans to docs/plans/ directory.`; + } + + return { + hasUnpersistedPlans: true, + plans: unpersistedPlans, + warningMessage, + hasHighPriority: highPriorityPlans.length > 0 + }; +} + +/** + * Mark a plan as persisted + * + * @param {string} sessionId - Current session ID + * @param {string} topic - Plan topic (partial match supported) + * @param {string} filePath - Path where plan was saved + */ +function markPlanPersisted(sessionId, topic, filePath) { + const state = loadPlansState(); + + // Find matching plan (partial topic match) + const plan = state.plans.find(p => + p.sessionId === sessionId && + !p.persisted && + (p.topic.toLowerCase().includes(topic.toLowerCase()) || + topic.toLowerCase().includes(p.topic.toLowerCase())) + ); + + if (plan) { + plan.persisted = true; + plan.persistedTo = filePath; + plan.persistedAt = new Date().toISOString(); + savePlansState(state); + return true; + } + + return false; +} + +/** + * Check if a file write is a plan persistence + * + * @param {string} filePath - Path being written + * @param {string} sessionId - Current session ID + */ +function checkPlanPersistence(filePath, sessionId) { + // Check if writing to docs/plans/ + if (filePath && filePath.includes('docs/plans/') && filePath.endsWith('.md')) { + const state = loadPlansState(); + + // Find any unpersisted plans for this session + const unpersistedPlans = state.plans.filter(p => + p.sessionId === sessionId && !p.persisted + ); + + // Mark the most recent/relevant one as persisted + if (unpersistedPlans.length > 0) { + // Mark all unpersisted plans as potentially persisted by this file + unpersistedPlans.forEach(plan => { + plan.persisted = true; + plan.persistedTo = filePath; + plan.persistedAt = new Date().toISOString(); + }); + + savePlansState(state); + + return { + markedAsPersisted: true, + plansUpdated: unpersistedPlans.length, + message: `āœ… Plan(s) marked as persisted to: ${filePath}` + }; + } + } + + return { markedAsPersisted: false }; +} + +/** + * Get session statistics + */ +function getSessionStats(sessionId) { + const state = loadPlansState(); + + const sessionPlans = state.plans.filter(p => p.sessionId === sessionId); + const persisted = sessionPlans.filter(p => p.persisted); + const unpersisted = sessionPlans.filter(p => !p.persisted); + + return { + totalPlans: sessionPlans.length, + persisted: persisted.length, + unpersisted: unpersisted.length, + plans: sessionPlans + }; +} + +/** + * Clean up old session plans (older than 24 hours) + */ +function cleanupOldPlans() { + const state = loadPlansState(); + const cutoff = Date.now() - (24 * 60 * 60 * 1000); // 24 hours + + state.plans = state.plans.filter(p => { + const planDate = new Date(p.startedAt).getTime(); + return planDate > cutoff; + }); + + savePlansState(state); +} + +// Export functions +module.exports = { + detectPlanningDiscussion, + trackPlanInProgress, + checkUnpersistedPlans, + markPlanPersisted, + checkPlanPersistence, + getSessionStats, + cleanupOldPlans, + PLANNING_KEYWORDS, + HIGH_PRIORITY_KEYWORDS +}; + +// CLI mode for testing +if (require.main === module) { + const args = process.argv.slice(2); + const command = args[0]; + + switch (command) { + case 'detect': + const message = args.slice(1).join(' '); + console.log(JSON.stringify(detectPlanningDiscussion(message), null, 2)); + break; + + case 'check': + const sessionId = args[1] || 'test-session'; + console.log(JSON.stringify(checkUnpersistedPlans(sessionId), null, 2)); + break; + + case 'stats': + const statsSessionId = args[1] || 'test-session'; + console.log(JSON.stringify(getSessionStats(statsSessionId), null, 2)); + break; + + case 'cleanup': + cleanupOldPlans(); + console.log('Old plans cleaned up'); + break; + + default: + console.log('Usage: node plan-persistence-manager.js [args]'); + } +}