#!/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]'); } }