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>
This commit is contained in:
TheFlow 2025-12-09 13:46:01 +13:00
parent 9bce9081c1
commit 92f70d7892
3 changed files with 755 additions and 0 deletions

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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 <detect|check|stats|cleanup> [args]');
}
}