- Add plan-detection-hook.js: Detects planning discussions in user prompts - Add plan-persistence-checker.js: Validates plan documentation on response - Add plan-persistence-manager.js: Manages plan tracking state Implements architectural enforcement for plan persistence (inst_comm_plan_001). Prevents accidental loss of planning discussions in Claude Code sessions. Incident Reference: 2025-12-01 lost plan documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
428 lines
11 KiB
JavaScript
Executable file
428 lines
11 KiB
JavaScript
Executable file
#!/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 <detect|check|stats|cleanup> [args]');
|
|
}
|
|
}
|