tractatus/.claude/hooks/plan-persistence-manager.js
TheFlow 92f70d7892 feat: Add plan persistence hooks for Claude Code
- 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>
2025-12-09 13:46:01 +13:00

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