Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:56:10 +08:00
commit 400ca062d1
48 changed files with 18674 additions and 0 deletions

109
hooks/compact_start.js Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Contextune CompactStart Hook
*
* Tracks when Claude Code compacts the conversation context.
* This reveals how well we preserve context for the main agent.
*
* Metrics:
* - Time from session start to first compact
* - Total compacts per session
* - Average time between compacts
*
* Context Cost: 0 tokens (hook runs outside conversation)
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function main() {
try {
const dbFile = path.join('.contextune', 'observability.db');
if (!fs.existsSync(dbFile)) {
console.error('DEBUG: Observability DB not found, skipping compact tracking');
process.exit(0);
}
const compactTime = Date.now() / 1000; // Unix timestamp
try {
// Get most recent session
const getSessionQuery = `
SELECT session_id, start_time, first_compact_time
FROM sessions
ORDER BY start_time DESC
LIMIT 1
`;
const sessionData = execSync(`sqlite3 "${dbFile}" "${getSessionQuery}"`, {
encoding: 'utf-8',
timeout: 1000
}).trim();
if (!sessionData) {
console.error('DEBUG: No active session found');
process.exit(0);
}
const [sessionId, startTime, firstCompactTime] = sessionData.split('|');
// Calculate duration if this is the first compact
if (!firstCompactTime || firstCompactTime === '') {
const duration = compactTime - parseFloat(startTime);
const updateQuery = `
UPDATE sessions
SET first_compact_time = ${compactTime},
duration_to_compact = ${duration}
WHERE session_id = '${sessionId}'
`;
execSync(`sqlite3 "${dbFile}" "${updateQuery}"`, {
stdio: 'pipe',
timeout: 1000
});
const minutes = (duration / 60).toFixed(1);
console.error(`DEBUG: First compact at ${new Date(compactTime * 1000).toISOString()}`);
console.error(`DEBUG: Context preserved for ${minutes} minutes`);
console.error(`DEBUG: 🎯 Contextune effectiveness: ${minutes} min of full context`);
} else {
// Not first compact - just log
const timeSinceFirst = compactTime - parseFloat(firstCompactTime);
console.error(`DEBUG: Subsequent compact at +${(timeSinceFirst / 60).toFixed(1)} min from first`);
}
// Log compact event to performance metrics
const logQuery = `
INSERT INTO performance_metrics (component, operation, latency_ms, timestamp, metadata)
VALUES ('session_tracking', 'compact_event', 0, ${compactTime}, '{"compact_time": ${compactTime}}')
`;
execSync(`sqlite3 "${dbFile}" "${logQuery}"`, {
stdio: 'pipe',
timeout: 1000
});
} catch (err) {
console.error('DEBUG: Failed to track compact event:', err.message);
}
// Always continue (don't block compaction)
const output = {
continue: true
};
console.log(JSON.stringify(output));
process.exit(0);
} catch (err) {
console.error('Contextune CompactStart hook error:', err.message);
// Always continue (don't block compaction)
console.log(JSON.stringify({ continue: true }));
process.exit(0);
}
}
main();

368
hooks/context_injector.js Executable file
View File

@@ -0,0 +1,368 @@
#!/usr/bin/env node
/**
* Context Injection Hook for Grounded Research
*
* Injects current context into research prompts:
* - Current date (for accurate web searches)
* - Tech stack (from package.json, etc.)
* - Existing specifications
* - Recent plans
*
* This hook runs BEFORE intent detection to ensure research is grounded.
*
* Requirements: Node.js (comes with Claude Code - no additional install needed!)
*/
const fs = require('fs');
const path = require('path');
/**
* Get current context from environment and codebase
*/
function getCurrentContext() {
// Get current date
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// Get working directory
const workingDir = process.cwd();
const projectName = path.basename(workingDir);
// Detect tech stack
const techStack = detectTechStack(workingDir);
// Find specifications
const specs = findSpecifications(workingDir);
// Find recent plans
const recentPlans = findRecentPlans(workingDir);
return {
date: currentDate,
project: projectName,
workingDir,
techStack,
specifications: specs,
recentPlans
};
}
/**
* Detect tech stack from project files
*/
function detectTechStack(workingDir) {
const stack = {};
// Check for package.json (Node.js/TypeScript)
const packageJsonPath = path.join(workingDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
stack.language = 'TypeScript/JavaScript';
stack.runtime = 'Node.js';
// Detect framework
const deps = pkg.dependencies || {};
if (deps.react) {
stack.framework = 'React';
} else if (deps.vue) {
stack.framework = 'Vue';
} else if (deps.svelte) {
stack.framework = 'Svelte';
} else if (deps.next) {
stack.framework = 'Next.js';
}
// Top dependencies
stack.dependencies = Object.keys(deps).slice(0, 10);
} catch (err) {
// Ignore parsing errors
}
}
// Check for pyproject.toml (Python)
const pyprojectPath = path.join(workingDir, 'pyproject.toml');
if (fs.existsSync(pyprojectPath)) {
stack.language = 'Python';
stack.packageManager = 'UV/pip';
}
// Check for go.mod (Go)
const goModPath = path.join(workingDir, 'go.mod');
if (fs.existsSync(goModPath)) {
stack.language = 'Go';
}
// Check for Cargo.toml (Rust)
const cargoPath = path.join(workingDir, 'Cargo.toml');
if (fs.existsSync(cargoPath)) {
stack.language = 'Rust';
}
return stack;
}
/**
* Find existing specification documents
*/
function findSpecifications(workingDir) {
const specs = [];
const specLocations = [
'docs/specs',
'docs/ARCHITECTURE.md',
'ARCHITECTURE.md',
'README.md',
'CONTRIBUTING.md',
'docs/DESIGN.md'
];
for (const location of specLocations) {
const fullPath = path.join(workingDir, location);
if (fs.existsSync(fullPath)) {
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
specs.push(location);
} else if (stat.isDirectory()) {
// Add all markdown files in specs directory
try {
const files = fs.readdirSync(fullPath);
for (const file of files) {
if (file.endsWith('.md')) {
specs.push(path.join(location, file));
}
}
} catch (err) {
// Ignore read errors
}
}
}
}
return specs;
}
/**
* Find recent development plans
*/
function findRecentPlans(workingDir) {
const plansDir = path.join(workingDir, '.parallel', 'plans');
if (!fs.existsSync(plansDir)) {
return [];
}
const now = new Date();
const recentPlans = [];
try {
const files = fs.readdirSync(plansDir);
for (const file of files) {
if (file.startsWith('PLAN-') && file.endsWith('.md')) {
try {
// Extract timestamp (PLAN-YYYYMMDD-HHMMSS.md)
const timestampStr = file.replace('PLAN-', '').replace('.md', '');
const dateStr = timestampStr.split('-')[0]; // YYYYMMDD
// Parse date
const year = parseInt(dateStr.substr(0, 4));
const month = parseInt(dateStr.substr(4, 2)) - 1;
const day = parseInt(dateStr.substr(6, 2));
const planDate = new Date(year, month, day);
// Calculate age in days
const ageDays = Math.floor((now - planDate) / (1000 * 60 * 60 * 24));
// Only include plans from last 30 days
if (ageDays <= 30) {
recentPlans.push({
file: path.join('.parallel', 'plans', file),
date: planDate.toISOString().split('T')[0],
ageDays
});
}
} catch (err) {
// Ignore parsing errors
}
}
}
} catch (err) {
// Ignore read errors
}
// Sort by age (newest first)
recentPlans.sort((a, b) => a.ageDays - b.ageDays);
return recentPlans.slice(0, 5); // Return 5 most recent
}
/**
* Format context for injection into prompt
*/
function formatContextForInjection(context) {
const lines = [];
lines.push('📋 RESEARCH CONTEXT (Use this information in your research!)');
lines.push('');
// Current date (CRITICAL for web searches)
lines.push(`**Current Date:** ${context.date}`);
lines.push('⚠️ IMPORTANT: When searching the web, use THIS date, not 2024!');
const year = context.date.split('-')[0];
lines.push(` Search for '${year}' or 'latest', not '2024'`);
lines.push('');
// Project info
lines.push(`**Project:** ${context.project}`);
lines.push(`**Directory:** ${context.workingDir}`);
lines.push('');
// Tech stack
if (Object.keys(context.techStack).length > 0) {
lines.push('**Tech Stack:**');
for (const [key, value] of Object.entries(context.techStack)) {
if (Array.isArray(value)) {
lines.push(`${key}: ${value.slice(0, 5).join(', ')}`);
} else {
lines.push(`${key}: ${value}`);
}
}
lines.push('');
}
// Existing specifications
if (context.specifications.length > 0) {
lines.push('**Existing Specifications (READ THESE FIRST!):**');
for (const spec of context.specifications) {
lines.push(`${spec}`);
}
lines.push('');
lines.push('⚠️ Do NOT research what\'s already specified!');
lines.push(' Read these docs to understand existing decisions.');
lines.push('');
}
// Recent plans
if (context.recentPlans.length > 0) {
lines.push('**Recent Development Plans:**');
for (const plan of context.recentPlans) {
lines.push(`${plan.file} (${plan.ageDays} days ago)`);
}
lines.push('');
lines.push('⚠️ Check if similar work was already planned!');
lines.push('');
}
lines.push('---');
lines.push('');
return lines.join('\n');
}
/**
* Check if prompt should receive context injection
*/
function shouldInjectContext(prompt) {
const keywords = [
'research',
'plan',
'parallel',
'execute',
'analyze',
'design',
'architect',
'implement'
];
const lowerPrompt = prompt.toLowerCase();
return keywords.some(keyword => lowerPrompt.includes(keyword));
}
/**
* Main hook execution
*/
function main() {
// Read stdin for hook input
let inputData = '';
process.stdin.on('data', chunk => {
inputData += chunk;
});
process.stdin.on('end', () => {
try {
// Parse input
const hookInput = JSON.parse(inputData);
const prompt = hookInput.prompt || '';
// Only inject context if prompt mentions research/plan/execute keywords
if (!shouldInjectContext(prompt)) {
// Don't inject, continue normally
process.exit(0);
return;
}
// Get current context
const context = getCurrentContext();
// Format context for injection
const contextBlock = formatContextForInjection(context);
// Inject at start of prompt
const modifiedPrompt = `${contextBlock}\n${prompt}`;
// Build feedback message to show user what was injected
const feedbackParts = [` Context injected: ${context.date}`];
if (context.techStack && Object.keys(context.techStack).length > 0) {
if (context.techStack.language) {
feedbackParts.push(context.techStack.language);
}
if (context.techStack.framework) {
feedbackParts.push(context.techStack.framework);
}
}
if (context.specifications && context.specifications.length > 0) {
feedbackParts.push(`${context.specifications.length} spec(s)`);
}
const feedback = feedbackParts.join(', ');
// Output modified prompt with user feedback
const output = {
continue: true,
modifiedPrompt: modifiedPrompt,
feedback: feedback,
suppressOutput: false
};
console.log(JSON.stringify(output));
process.exit(0);
} catch (err) {
// Fail silently on errors
// Log to stderr for debugging (won't affect hook output)
console.error('Context injection error:', err.message);
process.exit(0);
}
});
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
getCurrentContext,
detectTechStack,
findSpecifications,
findRecentPlans,
formatContextForInjection
};

363
hooks/context_preserver.py Executable file
View File

@@ -0,0 +1,363 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""
Enhanced Context Preservation Hook (PreCompact) with Checkpoint Pattern
Implements checkpoint pattern for long sessions:
1. Extracts COMPLETED plans to .plans/ (permanent storage)
2. Preserves IN-PROGRESS work to scratch_pad.md (ephemeral transfer)
3. Enables compact-after-plan workflow
Benefits:
- Each plan becomes a checkpoint (permanent)
- Compaction clears old discussion (reduces bloat)
- Working context stays focused on current plan
- Cumulative documentation without context pollution
"""
import json
import sys
import re
from pathlib import Path
from datetime import datetime
from typing import Optional, List
import yaml
# Import extraction functions from session_end_extractor
sys.path.insert(0, str(Path(__file__).parent))
# High-value patterns for in-progress work
HIGH_VALUE_PATTERNS = [
r'## Architecture',
r'## Implementation',
r'## Task \d+:',
r'## Solution:',
r'```yaml',
r'```python',
r'Option \d+:',
r'Let me design',
r'Enhanced schema:',
r'task-\d+\.md',
]
# Plan completion markers
PLAN_COMPLETION_MARKERS = [
r'\*\*Type:\*\* (Design|Plan|Architecture)',
r'\*\*Status:\*\* (Complete|Ready)',
r'## Success Criteria',
r'## Task Breakdown',
r'Ready for: /ctx:plan',
r'Ready for: /ctx:execute',
]
def read_full_transcript(transcript_path: str) -> List[dict]:
"""
Read full conversation transcript.
Args:
transcript_path: Path to transcript JSONL file
Returns:
List of conversation entries
"""
try:
with open(transcript_path, 'r') as f:
return [json.loads(line) for line in f if line.strip()]
except Exception as e:
print(f"DEBUG: Failed to read transcript: {e}", file=sys.stderr)
return []
def extract_assistant_text(entry: dict) -> Optional[str]:
"""Extract text from assistant message entry."""
if entry.get('type') != 'assistant':
return None
message = entry.get('message', {})
if not isinstance(message, dict):
return None
content = message.get('content', [])
# Handle both formats
if isinstance(content, str):
return content
elif isinstance(content, list):
return ' '.join(
block.get('text', '')
for block in content
if block.get('type') == 'text'
)
return None
def is_completed_plan(text: str) -> int:
"""
Detect if text contains a COMPLETED plan.
Returns count of completion markers (≥2 = completed plan)
"""
if not text:
return 0
count = 0
for pattern in PLAN_COMPLETION_MARKERS:
matches = re.findall(pattern, text, re.IGNORECASE)
count += len(matches)
return count
def extract_plans_from_transcript(transcript: List[dict]) -> List[dict]:
"""
Extract all completed plans from conversation.
Args:
transcript: Full conversation transcript
Returns:
List of {index, timestamp, content, completion_score} dicts
"""
plans = []
for i, entry in enumerate(transcript):
text = extract_assistant_text(entry)
if not text:
continue
completion_score = is_completed_plan(text)
# Require ≥2 completion markers for a plan
if completion_score >= 2:
plans.append({
'index': i,
'timestamp': entry.get('timestamp', ''),
'content': text,
'completion_score': completion_score
})
return plans
def extract_yaml_blocks(content: str) -> List[dict]:
"""Extract and parse YAML blocks."""
yaml_blocks = re.findall(r'```yaml\n(.*?)```', content, re.DOTALL)
parsed = []
for block in yaml_blocks:
try:
data = yaml.safe_load(block)
if data:
parsed.append(data)
except yaml.YAMLError:
continue
return parsed
def extract_title(content: str) -> Optional[str]:
"""Extract title from markdown (# Title)."""
match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
return match.group(1).strip() if match else None
def sanitize_topic(title: str) -> str:
"""Convert title to filesystem-safe slug."""
slug = re.sub(r'[^\w\s-]', '', title.lower())
slug = re.sub(r'[-\s]+', '-', slug)
return slug[:50]
def save_plan_to_disk(project_root: Path, plan: dict, session_id: str) -> bool:
"""
Save completed plan to .plans/ directory.
Args:
project_root: Project root directory
plan: Plan dict with content
session_id: Current session ID
Returns:
bool indicating success
"""
try:
content = plan['content']
# Extract title and create topic slug
title = extract_title(content) or 'untitled-plan'
topic_slug = sanitize_topic(title)
# Create .plans directory
plans_dir = project_root / '.plans' / topic_slug
plans_dir.mkdir(parents=True, exist_ok=True)
# Write design.md
design_file = plans_dir / 'design.md'
with open(design_file, 'w') as f:
f.write(content)
print(f"DEBUG: ✅ Checkpoint: Saved plan to {design_file}", file=sys.stderr)
# Extract and save tasks if present
yaml_blocks = extract_yaml_blocks(content)
task_count = 0
for yaml_data in yaml_blocks:
if 'tasks' in yaml_data and isinstance(yaml_data['tasks'], list):
tasks_dir = plans_dir / 'tasks'
tasks_dir.mkdir(exist_ok=True)
for task in yaml_data['tasks']:
if not isinstance(task, dict):
continue
task_id = task.get('id', f'task-{task_count + 1}')
task_file = tasks_dir / f"{task_id}.md"
with open(task_file, 'w') as f:
# YAML frontmatter
f.write('---\n')
yaml.dump(task, f, default_flow_style=False, sort_keys=False)
f.write('---\n\n')
# Task body
title = task.get('title', 'Untitled')
f.write(f"# {task_id}: {title}\n\n")
f.write(task.get('description', '(To be filled in)\n'))
task_count += 1
if task_count:
print(f"DEBUG: ✅ Checkpoint: Saved {task_count} task files", file=sys.stderr)
return True
except Exception as e:
print(f"DEBUG: Failed to save plan: {e}", file=sys.stderr)
return False
def extract_last_message_for_scratch_pad(transcript_path: str) -> Optional[str]:
"""Extract last Claude message for scratch_pad."""
try:
with open(transcript_path, 'r') as f:
lines = f.readlines()
for line in reversed(lines):
try:
entry = json.loads(line)
if entry.get('type') == 'assistant':
message = entry.get('message', {})
if isinstance(message, dict):
content = message.get('content', [])
if isinstance(content, list):
text = ' '.join(
block.get('text', '')
for block in content
if block.get('type') == 'text'
)
return text if text.strip() else None
except json.JSONDecodeError:
continue
return None
except Exception as e:
print(f"DEBUG: Failed to extract last message: {e}", file=sys.stderr)
return None
def write_scratch_pad(project_root: Path, content: str, session_id: str):
"""Write in-progress work to scratch_pad.md."""
scratch_pad = project_root / 'scratch_pad.md'
with open(scratch_pad, 'w') as f:
f.write(f"# In-Progress Context from Compaction\n\n")
f.write(f"**Session ID:** {session_id}\n")
f.write(f"**Preserved:** {datetime.now().isoformat()}\n")
f.write(f"**Auto-extracted by:** PreCompact hook\n\n")
f.write("---\n\n")
f.write(content)
print(f"DEBUG: ✅ Preserved in-progress work to scratch_pad.md", file=sys.stderr)
def main():
"""
Enhanced PreCompact hook with checkpoint pattern.
1. Extracts COMPLETED plans to .plans/ (checkpoints)
2. Preserves IN-PROGRESS work to scratch_pad.md
3. Enables compact-after-plan workflow
"""
try:
hook_data = json.loads(sys.stdin.read())
transcript_path = hook_data.get('transcript_path', '')
session_id = hook_data.get('session_id', 'unknown')
trigger = hook_data.get('trigger', 'unknown')
print(f"DEBUG: PreCompact checkpoint triggered ({trigger})", file=sys.stderr)
if not transcript_path or not Path(transcript_path).exists():
print("DEBUG: Transcript not found", file=sys.stderr)
output = {"continue": True}
print(json.dumps(output))
sys.exit(0)
# Find project root
project_root = Path.cwd()
transcript_dir = Path(transcript_path).parent
temp_root = transcript_dir
while temp_root.parent != temp_root:
if (temp_root / '.git').exists() or (temp_root / 'pyproject.toml').exists():
project_root = temp_root
break
temp_root = temp_root.parent
print(f"DEBUG: Project root: {project_root}", file=sys.stderr)
# Step 1: Extract COMPLETED plans (checkpoint pattern)
print(f"DEBUG: Scanning for completed plans...", file=sys.stderr)
transcript = read_full_transcript(transcript_path)
completed_plans = extract_plans_from_transcript(transcript)
print(f"DEBUG: Found {len(completed_plans)} completed plans", file=sys.stderr)
plans_saved = 0
for plan in completed_plans:
if save_plan_to_disk(project_root, plan, session_id):
plans_saved += 1
if plans_saved:
print(f"DEBUG: 🎯 Checkpoint: {plans_saved} completed plans saved to .plans/", file=sys.stderr)
# Step 2: Preserve IN-PROGRESS work to scratch_pad.md
last_message = extract_last_message_for_scratch_pad(transcript_path)
if last_message:
# Check if last message is in-progress work (not a completed plan)
completion_score = is_completed_plan(last_message)
if completion_score < 2:
# In-progress work - save to scratch_pad
pattern_count = len([p for p in HIGH_VALUE_PATTERNS
if re.search(p, last_message, re.IGNORECASE)])
if pattern_count >= 3:
write_scratch_pad(project_root, last_message, session_id)
print(f"DEBUG: ✅ Preserved in-progress work ({pattern_count} patterns)", file=sys.stderr)
else:
print(f"DEBUG: Last message is completed plan (already extracted)", file=sys.stderr)
# Summary
print(f"DEBUG: 📋 Checkpoint Summary:", file=sys.stderr)
print(f"DEBUG: Completed plans: {plans_saved} saved to .plans/", file=sys.stderr)
print(f"DEBUG: In-progress work: {'saved to scratch_pad.md' if last_message and completion_score < 2 else 'none'}", file=sys.stderr)
except Exception as e:
print(f"DEBUG: Checkpoint failed: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# Always continue
output = {"continue": True}
print(json.dumps(output))
sys.exit(0)
if __name__ == '__main__':
main()

164
hooks/context_restorer.js Executable file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Context Restorer (SessionStart Hook)
*
* Automatically injects preserved context from scratch_pad.md into new session.
* Complements PreCompact hook (context_preserver.py) for DRY workflow:
*
* Session 1: Work → /compact → PreCompact writes scratch_pad.md
* Session 2: SessionStart injects scratch_pad.md → Claude has context (no Read needed!)
*
* DRY Benefit: No redundant file reading - context injected once at session start.
*
* Context Cost: Variable (size of scratch_pad.md content, typically 2-5K tokens)
*/
const fs = require('fs');
const path = require('path');
/**
* Find project root by walking up from current directory
* @returns {string|null} Project root path or null if not found
*/
function findProjectRoot() {
let currentDir = process.cwd();
const root = path.parse(currentDir).root;
while (currentDir !== root) {
// Check for common project indicators
if (
fs.existsSync(path.join(currentDir, '.git')) ||
fs.existsSync(path.join(currentDir, 'pyproject.toml')) ||
fs.existsSync(path.join(currentDir, 'package.json')) ||
fs.existsSync(path.join(currentDir, 'Cargo.toml'))
) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
return null;
}
/**
* Read and format scratch_pad.md for injection
* @param {string} scratchPadPath Path to scratch_pad.md
* @returns {string|null} Formatted context or null if not found
*/
function readScratchPad(scratchPadPath) {
if (!fs.existsSync(scratchPadPath)) {
return null;
}
try {
const content = fs.readFileSync(scratchPadPath, 'utf8');
// Don't inject if file is empty or very small
if (content.trim().length < 100) {
return null;
}
// Format for injection
const formatted = [
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'📋 WORKING CONTEXT RESTORED FROM PREVIOUS SESSION',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'',
content,
'',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'✅ You can continue from where you left off.',
' Context preserved automatically by PreCompact hook.',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
''
].join('\n');
return formatted;
} catch (err) {
console.error(`DEBUG: Failed to read scratch_pad.md: ${err.message}`);
return null;
}
}
/**
* Delete scratch_pad.md after successful injection
* @param {string} scratchPadPath Path to scratch_pad.md
*/
function cleanupScratchPad(scratchPadPath) {
try {
if (fs.existsSync(scratchPadPath)) {
fs.unlinkSync(scratchPadPath);
console.error('DEBUG: ✅ Cleaned up scratch_pad.md (context injected)');
}
} catch (err) {
console.error(`DEBUG: Failed to cleanup scratch_pad.md: ${err.message}`);
// Non-fatal
}
}
/**
* Main hook execution
*/
function main() {
try {
// Read SessionStart event (contains source: startup|resume|clear|compact)
const event = JSON.parse(fs.readFileSync(0, 'utf-8'));
const source = event.source || 'unknown';
console.error(`DEBUG: SessionStart triggered (source: ${source})`);
// Find project root
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error('DEBUG: Project root not found, skipping context restoration');
process.exit(0);
}
console.error(`DEBUG: Project root: ${projectRoot}`);
// Check for scratch_pad.md
const scratchPadPath = path.join(projectRoot, 'scratch_pad.md');
const scratchPadContent = readScratchPad(scratchPadPath);
if (!scratchPadContent) {
console.error('DEBUG: No scratch_pad.md found or content too small');
// No context to restore, continue normally
const output = { continue: true };
console.log(JSON.stringify(output));
process.exit(0);
}
// Calculate token estimate (rough: 4 chars per token)
const estimatedTokens = Math.ceil(scratchPadContent.length / 4);
console.error(`DEBUG: Restoring context (~${estimatedTokens.toLocaleString()} tokens)`);
// Inject context via additionalContext
const output = {
continue: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: scratchPadContent
},
feedback: `📋 Working context restored from previous session (~${estimatedTokens.toLocaleString()} tokens)`,
suppressOutput: false // Show in transcript for transparency
};
console.log(JSON.stringify(output));
// Cleanup scratch_pad.md after successful injection
// (prevents re-injection in future sessions)
cleanupScratchPad(scratchPadPath);
process.exit(0);
} catch (err) {
console.error('Context restoration error:', err.message);
// Fail gracefully - don't block session
const output = { continue: true };
console.log(JSON.stringify(output));
process.exit(0);
}
}
main();

274
hooks/git_workflow_detector.py Executable file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Git Workflow Detector - PreToolUse Hook with Preference Management
Detects when Claude is about to use inefficient multi-tool git workflows
and suggests using optimized scripts instead.
Features:
- First detection: Uses AskUserQuestion for user choice
- User can set preference: "Always use scripts"
- Subsequent detections: Auto-use based on preference
- Subagents: Always use scripts (no prompting)
Triggers:
- Multiple git commands in single Bash call
- Sequential git operations (add, commit, push)
- PR/merge workflows
Does NOT block - provides suggestions only.
"""
import json
import sys
import re
from pathlib import Path
from datetime import datetime
# Preference storage
PREFERENCE_FILE = Path.home() / ".claude" / "plugins" / "contextune" / "data" / "git_workflow_preferences.json"
# Script mappings
SCRIPT_SUGGESTIONS = {
'commit_and_push': {
'patterns': [
r'git add.*git commit.*git push',
r'git commit.*git push',
],
'script': './scripts/commit_and_push.sh',
'usage': './scripts/commit_and_push.sh "." "message" "branch"',
'savings': '90-97% tokens, $0.035-0.084 cost reduction'
},
'create_pr': {
'patterns': [
r'gh pr create',
r'git push.*gh pr',
],
'script': './scripts/create_pr.sh',
'usage': './scripts/create_pr.sh "title" "body" "base" "head"',
'savings': '90-95% tokens, $0.030-0.080 cost reduction'
},
'merge_workflow': {
'patterns': [
r'git merge.*git push.*git branch -d',
r'git merge.*git branch.*delete',
],
'script': './scripts/merge_and_cleanup.sh',
'usage': './scripts/merge_and_cleanup.sh "branch" "into_branch"',
'savings': '90-95% tokens, $0.030-0.080 cost reduction'
}
}
def read_preference() -> dict:
"""
Read user's git workflow preference.
Returns:
dict with 'auto_use_scripts' (bool or None) and 'set_at' timestamp
"""
if not PREFERENCE_FILE.exists():
return {'auto_use_scripts': None, 'set_at': None}
try:
with open(PREFERENCE_FILE, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {'auto_use_scripts': None, 'set_at': None}
def write_preference(auto_use_scripts: bool):
"""
Write user's git workflow preference.
Args:
auto_use_scripts: Whether to automatically use scripts
"""
PREFERENCE_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {
'auto_use_scripts': auto_use_scripts,
'set_at': datetime.now().isoformat()
}
with open(PREFERENCE_FILE, 'w') as f:
json.dump(data, f, indent=2)
def detect_git_workflow(command: str) -> tuple[bool, dict]:
"""
Detect if command contains multi-step git workflow.
Args:
command: Bash command to analyze
Returns:
(is_workflow: bool, suggestion: dict)
"""
if 'git ' not in command:
return False, {}
# Check each workflow pattern
for workflow_name, workflow_info in SCRIPT_SUGGESTIONS.items():
for pattern in workflow_info['patterns']:
if re.search(pattern, command, re.IGNORECASE):
return True, {
'workflow': workflow_name,
'script': workflow_info['script'],
'usage': workflow_info['usage'],
'savings': workflow_info['savings']
}
# Check for multiple git commands (&&, ;, or newlines)
git_command_count = len(re.findall(r'\bgit\s+\w+', command))
if git_command_count >= 3:
return True, {
'workflow': 'multiple_git_commands',
'script': './scripts/smart_execute.sh',
'usage': 'Consider consolidating into a single script',
'savings': 'Reduces tool call overhead (~90% token reduction)'
}
return False, {}
def format_auto_use_message(suggestion: dict) -> str:
"""Format message when auto-using scripts (preference set)."""
return f"""
✨ AUTO-OPTIMIZATION ACTIVE
Using optimized script: {suggestion['script']}
(Your preference is set to auto-use scripts)
Benefits:
{suggestion['savings']}
• Automatic error recovery (Haiku/Copilot)
• Minimal session context impact
Change preference: Delete {PREFERENCE_FILE}
""".strip()
def format_ask_user_instruction(suggestion: dict) -> str:
"""Format instruction for Claude to use AskUserQuestion."""
return f"""
🎯 GIT WORKFLOW OPTIMIZATION DETECTED
Use AskUserQuestion tool with these settings:
Question: "Git workflow optimization available - how would you like to proceed?"
Header: "Optimize"
Options:
1. Use optimized script
Description: "Use {suggestion['script']} - {suggestion['savings']}, automatic error recovery"
2. Use standard approach
Description: "Proceed with multiple Bash tool calls (current method)"
3. Always use scripts
Description: "Set preference to auto-use scripts for all git workflows this session"
Based on user selection:
- Option 1: Execute {suggestion['script']} (one-time)
- Option 2: Continue with Bash as planned
- Option 3: Save preference, then execute {suggestion['script']}
To save preference (Option 3), create file at:
{PREFERENCE_FILE}
With content: {{"auto_use_scripts": true, "set_at": "{datetime.now().isoformat()}"}}
""".strip()
def format_suggestion_only(suggestion: dict) -> str:
"""Format simple suggestion when no preference system available."""
return f"""
💡 Git Workflow Optimization Available
Detected: Multi-step git operation ({suggestion['workflow']})
Optimized alternative:
{suggestion['script']}
Usage:
{suggestion['usage']}
Benefits:
{suggestion['savings']}
• Automatic error recovery (Haiku/Copilot cascade)
• Minimal session context impact
You can use the optimized script or proceed with current approach.
""".strip()
def main():
"""PreToolUse hook entry point."""
try:
hook_data = json.loads(sys.stdin.read())
tool = hook_data.get('tool', {})
tool_name = tool.get('name', '')
tool_params = tool.get('parameters', {})
# Only check Bash tool
if tool_name != 'Bash':
output = {'continue': True}
print(json.dumps(output))
sys.exit(0)
command = tool_params.get('command', '')
# Detect git workflows
is_workflow, suggestion = detect_git_workflow(command)
if not is_workflow or not suggestion:
# Not a git workflow, continue normally
output = {'continue': True}
print(json.dumps(output))
sys.exit(0)
# Workflow detected - check preference
preference = read_preference()
auto_use = preference.get('auto_use_scripts')
if auto_use is True:
# User prefers auto-use - suggest directly
message = format_auto_use_message(suggestion)
print(f"DEBUG: Auto-using scripts (preference set)", file=sys.stderr)
elif auto_use is False:
# User prefers Bash - don't suggest
print(f"DEBUG: User prefers Bash approach (preference set)", file=sys.stderr)
output = {'continue': True}
print(json.dumps(output))
sys.exit(0)
else:
# No preference - ask user with AskUserQuestion
message = format_ask_user_instruction(suggestion)
print(f"DEBUG: First detection, will prompt user via AskUserQuestion", file=sys.stderr)
# Inject suggestion/instruction
output = {
'continue': True,
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'additionalContext': message
}
}
print(json.dumps(output))
except Exception as e:
print(f"DEBUG: Git workflow detector error: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# Always continue (don't block tools)
output = {'continue': True}
print(json.dumps(output))
sys.exit(0)
if __name__ == '__main__':
main()

130
hooks/hooks.json Normal file
View File

@@ -0,0 +1,130 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session_start_git_context.js",
"timeout": 2000,
"description": "Git-powered context injection - differential updates since last session (~1-2K tokens)"
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/context_restorer.js",
"timeout": 2000,
"description": "Restore working context from scratch_pad.md (DRY context transfer)"
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.js",
"timeout": 1000,
"description": "Display Contextune commands (zero context overhead)"
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/version_checker.js",
"timeout": 3000,
"description": "Check for plugin updates (once per day)"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/context_injector.js",
"timeout": 1000,
"description": "Inject current context for grounded research"
},
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py",
"timeout": 5000,
"description": "SlashSense intent detection"
}
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use_state_sync.py",
"timeout": 1000,
"description": "State sync - check if files changed externally before Edit/Write (~300 tokens only when needed)"
},
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use_git_advisor.py",
"timeout": 1000,
"description": "Git workflow advisor - suggests scripts for multi-tool workflows (93-97% token reduction)"
},
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/tool_router.py",
"timeout": 2000,
"description": "Intelligent tool routing for cost optimization"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/tool_cost_tracker.py",
"timeout": 2000,
"description": "Track tool costs and optimization opportunities"
}
]
}
],
"PreCompact": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/context_preserver.py",
"timeout": 2000,
"description": "Automatically preserve working context to scratch_pad.md"
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/compact_start.js",
"timeout": 1000,
"description": "Track context compaction for measuring context preservation"
}
]
}
],
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/session_end_recorder.py",
"timeout": 2000,
"description": "Record session metadata for next session's git context (~100 tokens write only)"
},
{
"type": "command",
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/hooks/session_end_extractor.py",
"timeout": 5000,
"description": "Extract completed work to .plans/ and decisions.yaml (zero conversation overhead)"
}
]
}
]
}
}

191
hooks/pre_tool_use_git_advisor.py Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
PreToolUse Git Workflow Advisor
Intercepts Bash tool calls and detects git multi-tool workflows.
Provides non-blocking feedback suggesting script usage instead.
Layer 3 defense: Last line of defense against inefficient git workflows.
Hook Protocol:
- Input: JSON via stdin with tool invocation data
- Output: JSON via stdout with feedback
- IMPORTANT: Never blocks (always {"continue": true})
"""
import json
import re
import sys
def detect_git_workflow_in_bash(command: str) -> tuple[bool, str | None, str | None]:
"""
Detect git multi-tool workflows in Bash commands.
Returns: (is_workflow, workflow_type, script_recommendation)
"""
# Normalize command
cmd = command.strip()
# Pattern 1: git add && git commit
if re.search(r'git\s+add.*&&.*git\s+commit', cmd, re.IGNORECASE):
return True, "commit_workflow", "./scripts/commit_and_push.sh"
# Pattern 2: git commit && git push
if re.search(r'git\s+commit.*&&.*git\s+push', cmd, re.IGNORECASE):
return True, "commit_push_workflow", "./scripts/commit_and_push.sh"
# Pattern 3: git add && git commit && git push
if re.search(r'git\s+add.*&&.*git\s+commit.*&&.*git\s+push', cmd, re.IGNORECASE):
return True, "full_workflow", "./scripts/commit_and_push.sh"
# Pattern 4: Sequential git commands with ; or newlines
git_commands = re.findall(r'git\s+\w+', cmd, re.IGNORECASE)
if len(git_commands) >= 2:
# Multiple git commands in one call
return True, "multi_command", "./scripts/commit_and_push.sh"
# Pattern 5: git checkout && git pull && git merge
if re.search(r'git\s+checkout.*&&.*git\s+(pull|merge)', cmd, re.IGNORECASE):
return True, "merge_workflow", "./scripts/merge_and_cleanup.sh"
return False, None, None
def calculate_token_waste(workflow_type: str) -> tuple[int, int, float]:
"""
Calculate token waste for multi-tool approach vs script.
Returns: (multi_tool_tokens, script_tokens, savings_percent)
"""
# Conservative estimates
multi_tool_tokens = {
"commit_workflow": 8000,
"commit_push_workflow": 15000,
"full_workflow": 25000,
"multi_command": 12000,
"merge_workflow": 18000,
}
script_tokens = 545 # Average for script-based approach
tokens_wasted = multi_tool_tokens.get(workflow_type, 10000)
savings_percent = ((tokens_wasted - script_tokens) / tokens_wasted) * 100
return tokens_wasted, script_tokens, savings_percent
def main():
"""PreToolUse hook entry point."""
try:
# Read hook data from stdin
hook_data = json.loads(sys.stdin.read())
tool_name = hook_data.get("tool_name", "")
tool_input = hook_data.get("tool_input", {})
# DEBUG logging
print(f"DEBUG: PreToolUse triggered for tool: {tool_name}", file=sys.stderr)
# Only intercept Bash tool calls
if tool_name != "Bash":
# Not a Bash call, continue without feedback
response = {"continue": True}
print(json.dumps(response))
return
# Get the bash command
command = tool_input.get("command", "")
print(f"DEBUG: Bash command: {command[:100]}...", file=sys.stderr)
# Detect git workflows
is_workflow, workflow_type, script_recommendation = detect_git_workflow_in_bash(command)
if not is_workflow:
# Not a git workflow, continue
response = {"continue": True}
print(json.dumps(response))
return
# Git workflow detected! Provide non-blocking feedback
print(f"DEBUG: Git workflow detected: {workflow_type}", file=sys.stderr)
# Calculate token waste
multi_tokens, script_tokens, savings = calculate_token_waste(workflow_type)
# Build feedback message
feedback = f"""🚨 Git Workflow Inefficiency Detected
**Detected:** Multi-tool git workflow in Bash command
**Type:** {workflow_type.replace('_', ' ').title()}
**Current approach:**
```bash
{command[:200]}{"..." if len(command) > 200 else ""}
```
**Cost:** ~{multi_tokens:,} tokens (~${multi_tokens * 0.003 / 1000:.3f})
---
💡 **Recommended:** Use deterministic script instead
**Script:** `{script_recommendation}`
**Benefits:**
- ✅ {savings:.0f}% token reduction
- ✅ Cost: ~{script_tokens} tokens (~${script_tokens * 0.003 / 1000:.3f})
- ✅ Savings: ~{multi_tokens - script_tokens:,} tokens per invocation
- ✅ Deterministic, tested workflow
- ✅ Auto-detects remote
- ✅ Proper error handling
**Example usage:**
```bash
# Instead of multiple git commands:
{script_recommendation} "." "feat: add feature
Detailed commit message here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
**Slash Command:** Use `/ctx:git-commit` for guided workflow
**See:** UNIFIED_DRY_STRATEGY.md for git workflow guidelines
---
⚠️ **Note:** Continuing with your original command, but **consider using the script** for future efficiency.
"""
# Log the detection
print(f"DEBUG: Providing feedback for {workflow_type}", file=sys.stderr)
print(f"DEBUG: Token waste: {multi_tokens - script_tokens:,} tokens ({savings:.0f}% reduction)", file=sys.stderr)
# IMPORTANT: Never block, always continue
response = {
"continue": True,
"feedback": feedback,
"suppressOutput": False # Show feedback to user
}
print(json.dumps(response))
except Exception as e:
# Never fail the hook - always continue
print(f"DEBUG: PreToolUse error: {e}", file=sys.stderr)
response = {"continue": True}
print(json.dumps(response))
if __name__ == "__main__":
main()

230
hooks/pre_tool_use_state_sync.py Executable file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
PreToolUse State Sync - Git-Powered File State Awareness
Intercepts file operations (Write/Edit/NotebookEdit) and checks if files
changed externally since Claude last read them.
Uses git as source of truth for current state.
**Token Overhead:** ~200-500 tokens (only when files changed externally)
**Blocking:** No (provides feedback, always continues)
Hook Protocol:
- Input: JSON via stdin with tool invocation data
- Output: JSON via stdout with feedback
- IMPORTANT: Never blocks (always {"continue": true})
"""
import json
import subprocess
import sys
from pathlib import Path
from datetime import datetime
def get_file_git_status(file_path: str) -> tuple[bool, str]:
"""
Check if file has uncommitted changes or differs from last commit.
Returns: (has_changes, status_description)
"""
try:
# Check git status for this specific file
result = subprocess.run(
["git", "status", "--short", file_path],
capture_output=True,
text=True,
timeout=1,
)
status = result.stdout.strip()
if not status:
# File unchanged
return False, "unchanged"
# Parse git status codes
# M = Modified (staged)
# _M = Modified (unstaged)
# ?? = Untracked
# A = Added
# D = Deleted
if status.startswith("M") or status.startswith(" M"):
return True, "modified"
elif status.startswith("??"):
return True, "untracked"
elif status.startswith("A"):
return True, "added"
elif status.startswith("D"):
return True, "deleted"
else:
return True, status[:2]
except Exception as e:
print(f"DEBUG: Failed to check git status: {e}", file=sys.stderr)
return False, "unknown"
def get_file_diff_summary(file_path: str) -> str:
"""
Get summary of changes in file since last commit.
Returns: Diff summary (lines added/removed)
"""
try:
# Get diff stat
result = subprocess.run(
["git", "diff", "HEAD", file_path, "--stat"],
capture_output=True,
text=True,
timeout=2,
)
diff_stat = result.stdout.strip()
if not diff_stat:
return "No changes"
# Extract line changes from stat
# Example: "file.py | 10 +++++-----"
return diff_stat
except Exception as e:
print(f"DEBUG: Failed to get diff summary: {e}", file=sys.stderr)
return "Unknown changes"
def check_file_tracked_in_cache(file_path: str) -> tuple[bool, str | None]:
"""
Check if Claude has read this file in current session.
Uses simple cache file to track reads.
Returns: (was_read, last_read_hash)
"""
try:
cache_dir = Path.home() / ".claude" / "plugins" / "contextune" / ".cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / "read_files.json"
if not cache_file.exists():
return False, None
with open(cache_file) as f:
cache = json.load(f)
file_info = cache.get(file_path)
if file_info:
return True, file_info.get("git_hash")
return False, None
except Exception as e:
print(f"DEBUG: Failed to check read cache: {e}", file=sys.stderr)
return False, None
def main():
"""PreToolUse hook entry point."""
try:
# Read hook data from stdin
hook_data = json.loads(sys.stdin.read())
tool_name = hook_data.get("tool_name", "")
tool_input = hook_data.get("tool_input", {})
# DEBUG logging
print(f"DEBUG: PreToolUse state sync for tool: {tool_name}", file=sys.stderr)
# Only intercept file operation tools
if tool_name not in ["Write", "Edit", "NotebookEdit"]:
# Not a file operation, continue without feedback
response = {"continue": True}
print(json.dumps(response))
return
# Get file path from tool input
file_path = tool_input.get("file_path")
if not file_path:
# No file path, continue
response = {"continue": True}
print(json.dumps(response))
return
print(f"DEBUG: Checking state for file: {file_path}", file=sys.stderr)
# Check git status
has_changes, status = get_file_git_status(file_path)
if not has_changes:
# File unchanged, continue without feedback
response = {"continue": True}
print(json.dumps(response))
return
# File has external changes!
print(f"DEBUG: File has external changes: {status}", file=sys.stderr)
# Get diff summary
diff_summary = get_file_diff_summary(file_path)
# Build feedback message
feedback = f"""⚠️ File State Change Detected
**File:** `{file_path}`
**Status:** {status.upper()}
**Git Says:** File has uncommitted changes since last commit
**Diff Summary:**
```
{diff_summary}
```
**Recommendation:**
- ✅ Re-read the file to see current state
- ✅ Use Read tool before {tool_name} to avoid conflicts
**Git Source of Truth:**
The file's current state may differ from what you have in context.
Git tracking shows external modifications.
**Example:**
```bash
# Check current state
git diff {file_path}
# See what changed
git log -1 --oneline -- {file_path}
```
**Note:** Continuing with your {tool_name} operation, but verify file state first!
"""
# Log the detection
print(f"DEBUG: Providing state sync feedback for {file_path}", file=sys.stderr)
# IMPORTANT: Never block, always continue with feedback
response = {
"continue": True,
"feedback": feedback,
"suppressOutput": False # Show feedback to Claude
}
print(json.dumps(response))
except Exception as e:
# Never fail the hook - always continue
print(f"DEBUG: PreToolUse state sync error: {e}", file=sys.stderr)
response = {"continue": True}
print(json.dumps(response))
if __name__ == "__main__":
main()

819
hooks/session_end_extractor.py Executable file
View File

@@ -0,0 +1,819 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""
SessionEnd Extractor - Extract completed work to structured files
Runs when session ends (user quits, closes tab, session timeout).
Scans full conversation transcript and extracts:
- Design proposals → .plans/[topic]/design.md
- Task breakdowns → .plans/[topic]/tasks/task-*.md
- Decisions → decisions.yaml (append)
- Research → decisions.yaml (append)
Zero conversation overhead - runs after session ends.
Leverages extraction-optimized output style for reliable parsing.
"""
import json
import sys
import re
from pathlib import Path
from datetime import datetime
from typing import Optional
import yaml
def extract_designs(transcript: list[dict]) -> list[dict]:
"""
Find all design proposals in conversation.
Detection patterns (from extraction-optimized style):
- **Type:** Design
- ## Architecture
- ## Task Breakdown
- Multiple YAML blocks
"""
designs = []
for i, entry in enumerate(transcript):
if entry.get("type") != "assistant":
continue
message = entry.get("message", {})
if isinstance(message, dict):
content = message.get("content", [])
# Handle both old format (string) and new format (list)
if isinstance(content, str):
text = content
elif isinstance(content, list):
# Extract text from content blocks
text = " ".join(
block.get("text", "")
for block in content
if block.get("type") == "text"
)
else:
continue
else:
continue
# Detect extraction-optimized design patterns
patterns = [
r"\*\*Type:\*\* Design",
r"## Architecture",
r"## Task Breakdown",
r"```yaml\n.*?architecture:",
r"```yaml\n.*?tasks:",
r"\*\*Status:\*\* (Complete|Draft)",
r"\*\*Estimated Tokens:\*\*",
]
pattern_count = sum(
len(re.findall(p, text, re.IGNORECASE | re.DOTALL)) for p in patterns
)
# Require at least 3 patterns for design detection
if pattern_count >= 3:
designs.append(
{
"index": i,
"timestamp": entry.get("timestamp", ""),
"content": text,
"pattern_count": pattern_count,
}
)
return designs
def extract_plans(transcript: list[dict]) -> list[dict]:
"""
Find all parallel development plans in conversation.
Detection patterns (from extraction-optimized style):
- **Type:** Plan
- ## Plan Structure
- YAML block with metadata: and tasks:
- ## Task Details
"""
plans = []
for i, entry in enumerate(transcript):
if entry.get("type") != "assistant":
continue
message = entry.get("message", {})
if isinstance(message, dict):
content = message.get("content", [])
# Handle both old format (string) and new format (list)
if isinstance(content, str):
text = content
elif isinstance(content, list):
# Extract text from content blocks
text = " ".join(
block.get("text", "")
for block in content
if block.get("type") == "text"
)
else:
continue
else:
continue
# Detect extraction-optimized plan patterns
patterns = [
r"\*\*Type:\*\* Plan",
r"## Plan Structure",
r"## Task Details",
r"```yaml\n.*?metadata:",
r"```yaml\n.*?tasks:",
r"\*\*Status:\*\* (Ready|Draft)",
]
pattern_count = sum(
len(re.findall(p, text, re.IGNORECASE | re.DOTALL)) for p in patterns
)
# Require at least 3 patterns for plan detection
if pattern_count >= 3:
plans.append(
{
"index": i,
"timestamp": entry.get("timestamp", ""),
"content": text,
"pattern_count": pattern_count,
}
)
return plans
def extract_yaml_blocks(content: str) -> list[dict]:
"""
Extract YAML blocks from markdown content.
Expects: ```yaml\n...\n```
"""
yaml_blocks = re.findall(r"```yaml\n(.*?)```", content, re.DOTALL)
parsed = []
for block in yaml_blocks:
try:
data = yaml.safe_load(block)
if data: # Skip empty blocks
parsed.append(data)
except yaml.YAMLError as e:
print(f"DEBUG: Failed to parse YAML block: {e}", file=sys.stderr)
continue
return parsed
def extract_title(content: str) -> Optional[str]:
"""
Extract title from markdown.
Pattern: # [Title] at start of content
"""
match = re.search(r"^#\s+(.+?)$", content, re.MULTILINE)
if match:
return match.group(1).strip()
return None
def extract_metadata(content: str) -> dict:
"""
Extract metadata from extraction-optimized format.
Patterns:
- **Type:** Design
- **Status:** Complete
- **Estimated Tokens:** 45000
"""
metadata = {}
type_match = re.search(r"\*\*Type:\*\*\s+(.+?)(?:\n|\|)", content)
if type_match:
metadata["type"] = type_match.group(1).strip()
status_match = re.search(r"\*\*Status:\*\*\s+(.+?)(?:\n|\|)", content)
if status_match:
metadata["status"] = status_match.group(1).strip()
tokens_match = re.search(r"\*\*Estimated Tokens:\*\*\s+([\d,]+)", content)
if tokens_match:
tokens_str = tokens_match.group(1).replace(",", "")
metadata["estimated_tokens"] = int(tokens_str)
return metadata
def sanitize_topic(title: str) -> str:
"""Convert title to filesystem-safe slug."""
# Remove special chars, convert to lowercase, replace spaces with hyphens
slug = re.sub(r"[^\w\s-]", "", title.lower())
slug = re.sub(r"[-\s]+", "-", slug)
return slug[:50] # Limit length
def write_design_files(project_root: Path, designs: list[dict], session_id: str) -> int:
"""
Write extracted designs to .plans/ directory.
Returns: Number of designs written
"""
if not designs:
return 0
# Use most comprehensive design (highest pattern count)
best_design = max(designs, key=lambda d: d["pattern_count"])
content = best_design["content"]
# Extract metadata
title = extract_title(content) or "untitled-design"
metadata = extract_metadata(content)
topic_slug = sanitize_topic(title)
# Create .plans directory structure
plans_dir = project_root / ".plans" / topic_slug
plans_dir.mkdir(parents=True, exist_ok=True)
# Write design.md
design_file = plans_dir / "design.md"
with open(design_file, "w") as f:
f.write(content)
print(f"DEBUG: ✅ Wrote design to {design_file}", file=sys.stderr)
# Extract and write task files
task_count = write_task_files(plans_dir, content)
return 1
def write_task_files(plans_dir: Path, content: str) -> int:
"""
Extract tasks from YAML blocks and write individual task files.
Returns: Number of task files written
"""
yaml_blocks = extract_yaml_blocks(content)
task_count = 0
for yaml_data in yaml_blocks:
if "tasks" in yaml_data:
tasks_dir = plans_dir / "tasks"
tasks_dir.mkdir(exist_ok=True)
tasks_list = yaml_data["tasks"]
if not isinstance(tasks_list, list):
continue
for task in tasks_list:
if not isinstance(task, dict):
continue
task_id = task.get("id", f"task-{task_count + 1}")
task_file = tasks_dir / f"{task_id}.md"
with open(task_file, "w") as f:
# Write YAML frontmatter
f.write("---\n")
yaml.dump(task, f, default_flow_style=False, sort_keys=False)
f.write("---\n\n")
# Write task details
title = task.get("title", "Untitled Task")
f.write(f"# {task_id}: {title}\n\n")
f.write("## Description\n\n")
f.write(task.get("description", "(To be filled in)\n\n"))
# Files section
files_created = task.get("files_created", [])
files_modified = task.get("files_modified", [])
if files_created or files_modified:
f.write("## Files\n\n")
if files_created:
f.write("**Created:**\n")
for file_info in files_created:
if isinstance(file_info, dict):
path = file_info.get("path", "")
purpose = file_info.get("purpose", "")
f.write(f"- `{path}` - {purpose}\n")
if files_modified:
f.write("\n**Modified:**\n")
for file_info in files_modified:
if isinstance(file_info, dict):
path = file_info.get("path", "")
changes = file_info.get("changes", "")
f.write(f"- `{path}` - {changes}\n")
# Validation section
validation = task.get("validation", [])
if validation:
f.write("\n## Validation Checklist\n\n")
for item in validation:
f.write(f"- [ ] {item}\n")
task_count += 1
if task_count:
print(f"DEBUG: ✅ Wrote {task_count} task files", file=sys.stderr)
return task_count
def write_plan_files(project_root: Path, plans: list[dict], session_id: str) -> int:
"""
Write extracted plans to .parallel/plans/ directory.
Returns: Number of plans written
"""
if not plans:
return 0
# Use most comprehensive plan (highest pattern count)
best_plan = max(plans, key=lambda p: p["pattern_count"])
content = best_plan["content"]
# Extract plan YAML from ## Plan Structure section
plan_yaml_match = re.search(
r"## Plan Structure\s*```yaml\n(.*?)```", content, re.DOTALL | re.IGNORECASE
)
if not plan_yaml_match:
print("DEBUG: Could not find Plan Structure YAML block", file=sys.stderr)
return 0
try:
plan_data = yaml.safe_load(plan_yaml_match.group(1))
except yaml.YAMLError as e:
print(f"DEBUG: Failed to parse plan YAML: {e}", file=sys.stderr)
return 0
# Extract plan name for directory
plan_name = plan_data.get("metadata", {}).get("name", "untitled-plan")
plan_slug = sanitize_topic(plan_name)
# Create .parallel/plans directory
plans_dir = project_root / ".parallel" / "plans"
plans_dir.mkdir(parents=True, exist_ok=True)
# Write plan.yaml
plan_file = plans_dir / "plan.yaml"
with open(plan_file, "w") as f:
yaml.dump(plan_data, f, default_flow_style=False, sort_keys=False)
print(f"DEBUG: ✅ Wrote plan to {plan_file}", file=sys.stderr)
# Extract and write task files from ## Task Details sections
task_pattern = r"### Task (\d+):\s*(.+?)\n.*?```yaml\n(.*?)```\n(.*?)(?=###|---|\Z)"
task_matches = re.findall(task_pattern, content, re.DOTALL)
if task_matches:
tasks_dir = plans_dir / "tasks"
tasks_dir.mkdir(exist_ok=True)
for task_num, task_name, task_yaml_str, task_content in task_matches:
try:
task_yaml = yaml.safe_load(task_yaml_str)
except yaml.YAMLError as e:
print(
f"DEBUG: Failed to parse task-{task_num} YAML: {e}", file=sys.stderr
)
continue
task_id = task_yaml.get("id", f"task-{task_num}")
task_file = tasks_dir / f"{task_id}.md"
with open(task_file, "w") as f:
# Write YAML frontmatter
f.write("---\n")
yaml.dump(task_yaml, f, default_flow_style=False, sort_keys=False)
f.write("---\n\n")
# Write task name
f.write(f"# {task_name.strip()}\n\n")
# Write task content
f.write(task_content.strip())
f.write("\n")
print(
f"DEBUG: ✅ Wrote {len(task_matches)} task files", file=sys.stderr
)
# Create helper scripts (templates)
scripts_dir = plans_dir / "scripts"
templates_dir = plans_dir / "templates"
scripts_dir.mkdir(exist_ok=True)
templates_dir.mkdir(exist_ok=True)
# Helper scripts content would go here (add_task.sh, generate_full.sh)
# For now, just create the directories
return 1
def extract_decisions(transcript: list[dict]) -> list[dict]:
"""
Find architectural decisions in conversation.
Detection patterns:
- ## Decision:
- **Status:** Accepted|Proposed|Rejected
- ### Alternatives Considered
"""
decisions = []
for entry in transcript:
if entry.get("type") != "assistant":
continue
message = entry.get("message", {})
if isinstance(message, dict):
content = message.get("content", [])
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = " ".join(
block.get("text", "")
for block in content
if block.get("type") == "text"
)
else:
continue
else:
continue
decision_patterns = [
r"## Decision:",
r"\*\*Status:\*\* (Accepted|Proposed|Rejected)",
r"### Alternatives Considered",
r"### Context",
r"### Consequences",
]
if sum(len(re.findall(p, text, re.IGNORECASE)) for p in decision_patterns) >= 3:
decisions.append({"timestamp": entry.get("timestamp", ""), "content": text})
return decisions
def extract_decision_data(
content: str, timestamp: str, session_id: str
) -> Optional[dict]:
"""
Extract structured decision data from content.
Expected format:
## Decision: {title}
**Date:** YYYY-MM-DD
**Status:** Accepted|Rejected|Pending|Revisiting
### Context
{context}
### Alternatives Considered
#### Option 1: ...
**Result:** ✅/❌ ...
### Consequences
**Positive:**
- {benefit}
**Negative:**
- {consequence}
Returns: Structured decision dict, or None if parsing fails
"""
decision = {}
# Extract title from "## Decision: {title}"
title_match = re.search(r"## Decision:\s*(.+?)(?:\n|$)", content)
if not title_match:
return None
decision["title"] = title_match.group(1).strip()
# Extract date
date_match = re.search(r"\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})", content)
if date_match:
decision["date"] = f"{date_match.group(1)}T00:00:00Z"
else:
decision["date"] = datetime.now().isoformat() + "Z"
# Extract status
status_match = re.search(
r"\*\*Status:\*\*\s*(Accepted|Rejected|Pending|Revisiting)",
content,
re.IGNORECASE,
)
if status_match:
status = status_match.group(1).lower()
decision["status"] = status
else:
decision["status"] = "pending"
# Extract context (between ### Context and ### Alternatives)
context_match = re.search(
r"### Context\s*\n(.*?)(?=###|\Z)", content, re.DOTALL | re.IGNORECASE
)
if context_match:
decision["context"] = context_match.group(1).strip()
# Extract alternatives considered
alternatives = []
# Find alternatives section - look for "### Alternatives" header
alt_match = re.search(r"###\s+Alternatives[^\n]*\n+", content)
if alt_match:
alt_start_idx = alt_match.end() # Position after header and newlines
# Find next section header (### with exactly 3 hashes, followed by non-hash)
rest = content[alt_start_idx:]
next_section = re.search(r"\n###[^#]", rest)
if next_section:
alternatives_text = content[
alt_start_idx : alt_start_idx + next_section.start() + 1
]
else:
alternatives_text = rest
# Parse each option: #### Option X: {title}
option_matches = re.finditer(
r"#### Option (\d+):\s*(.+?)\n(.*?)(?=####|\Z)",
alternatives_text,
re.DOTALL,
)
for option_match in option_matches:
option_title = option_match.group(2).strip()
option_content = option_match.group(3).strip()
alt = {"option": option_title}
# Extract result (✅ Selected, ❌ Rejected)
result_match = re.search(r"\*\*Result:\*\*\s*(.+?)(?:\n|$)", option_content)
if result_match:
result = result_match.group(1).strip()
if "" in result or "selected" in result.lower():
alt["result"] = "selected"
elif "" in result or "rejected" in result.lower():
alt["result"] = "rejected"
else:
alt["result"] = "considered"
# Extract pros
pros_match = re.search(
r"(?:^|\n)(?:pros|Pros):\s*\n(.*?)(?=(?:^|\n)(?:cons|Cons)|\Z)",
option_content,
re.DOTALL | re.MULTILINE,
)
if pros_match:
pros_text = pros_match.group(1)
pros = [
line.strip().lstrip("-").strip()
for line in pros_text.split("\n")
if line.strip().startswith("-")
]
if pros:
alt["pros"] = pros
# Extract cons
cons_match = re.search(
r"(?:^|\n)(?:cons|Cons):\s*\n(.*?)(?=\Z)",
option_content,
re.DOTALL | re.MULTILINE,
)
if cons_match:
cons_text = cons_match.group(1)
cons = [
line.strip().lstrip("-").strip()
for line in cons_text.split("\n")
if line.strip().startswith("-")
]
if cons:
alt["cons"] = cons
alternatives.append(alt)
if alternatives:
decision["alternatives_considered"] = alternatives
# Extract consequences
consequences = {}
cons_start_idx = content.lower().find("### consequences")
if cons_start_idx >= 0:
# Extract from ### Consequences to end of content
cons_text = content[cons_start_idx + len("### consequences") :]
# Extract positive consequences - look for "Positive" (with optional ** before and after colon)
# Pattern matches: **Positive:** or Positive: or Positive** etc.
positive_match = re.search(
r"\*{0,2}[Pp]ositive\*{0,2}\s*:\s*\*{0,2}\s*\n(.*?)(?=\*{0,2}[Nn]egative|\Z)",
cons_text,
re.DOTALL | re.IGNORECASE,
)
if positive_match:
positive_text = positive_match.group(1)
positives = [
line.strip().lstrip("-").strip()
for line in positive_text.split("\n")
if line.strip().startswith("-")
]
if positives:
consequences["positive"] = positives
# Extract negative consequences
negative_match = re.search(
r"\*{0,2}[Nn]egative\*{0,2}\s*:\s*\*{0,2}\s*\n(.*?)(?=\Z)",
cons_text,
re.DOTALL | re.IGNORECASE,
)
if negative_match:
negative_text = negative_match.group(1)
negatives = [
line.strip().lstrip("-").strip()
for line in negative_text.split("\n")
if line.strip().startswith("-")
]
if negatives:
consequences["negative"] = negatives
if consequences:
decision["consequences"] = consequences
# Add conversation link
decision["conversation_link"] = {
"session_id": session_id,
"timestamp": int(
datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() * 1000
)
if timestamp
else None,
}
# Add creation timestamp
decision["created_at"] = timestamp or datetime.now().isoformat() + "Z"
return decision
def append_decisions(project_root: Path, decisions: list[dict], session_id: str) -> int:
"""
Append extracted decisions to decisions.yaml.
Returns: Number of decisions appended
"""
if not decisions:
return 0
decisions_file = project_root / "decisions.yaml"
if not decisions_file.exists():
print(f"DEBUG: decisions.yaml not found at {decisions_file}", file=sys.stderr)
return 0
# Load existing decisions.yaml
try:
with open(decisions_file, "r") as f:
data = yaml.safe_load(f) or {}
except Exception as e:
print(f"DEBUG: Failed to load decisions.yaml: {e}", file=sys.stderr)
return 0
# Ensure 'decisions' section exists
if "decisions" not in data:
data["decisions"] = {"entries": []}
if "entries" not in data["decisions"]:
data["decisions"]["entries"] = []
# Extract and append each decision
appended_count = 0
existing_entries = data["decisions"].get("entries", [])
for decision_entry in decisions:
content = decision_entry.get("content", "")
timestamp = decision_entry.get("timestamp", "")
# Parse decision data
decision_data = extract_decision_data(content, timestamp, session_id)
if not decision_data:
continue
# Generate unique ID based on title and timestamp
title_slug = sanitize_topic(decision_data["title"])
timestamp_ms = decision_data["conversation_link"].get("timestamp", 0)
decision_id = f"dec-{timestamp_ms % 1000000:06d}-{title_slug[:20]}"
# Check if similar decision already exists
existing_ids = [e.get("id") for e in existing_entries if isinstance(e, dict)]
if decision_id in existing_ids:
print(
f"DEBUG: Decision {decision_id} already exists, skipping",
file=sys.stderr,
)
continue
decision_data["id"] = decision_id
# Append to entries list
existing_entries.append(decision_data)
appended_count += 1
# Update entries
data["decisions"]["entries"] = existing_entries
# Write back to decisions.yaml atomically
try:
with open(decisions_file, "w") as f:
yaml.dump(
data, f, default_flow_style=False, sort_keys=False, allow_unicode=True
)
print(
f"DEBUG: ✅ Appended {appended_count} decisions to decisions.yaml",
file=sys.stderr,
)
except Exception as e:
print(f"DEBUG: Failed to write decisions.yaml: {e}", file=sys.stderr)
return 0
return appended_count
def main():
"""
SessionEnd hook entry point.
Reads full transcript, extracts completed work, writes structured files.
"""
try:
# Read hook data
hook_data = json.loads(sys.stdin.read())
transcript_path = hook_data.get("transcript_path", "")
session_id = hook_data.get("session_id", "unknown")
print(f"DEBUG: SessionEnd extractor triggered", file=sys.stderr)
print(f"DEBUG: Session: {session_id}", file=sys.stderr)
print(f"DEBUG: Transcript: {transcript_path}", file=sys.stderr)
if not transcript_path or not Path(transcript_path).exists():
print(f"DEBUG: Transcript not found, skipping extraction", file=sys.stderr)
output = {"continue": True}
print(json.dumps(output))
sys.exit(0)
# Read full transcript
with open(transcript_path, "r") as f:
transcript = [json.loads(line) for line in f if line.strip()]
print(f"DEBUG: Loaded {len(transcript)} conversation entries", file=sys.stderr)
# Find project root from first entry's cwd
project_root = Path.cwd()
if transcript:
cwd = transcript[0].get("cwd")
if cwd:
project_root = Path(cwd)
print(f"DEBUG: Project root: {project_root}", file=sys.stderr)
# Extract components
designs = extract_designs(transcript)
plans = extract_plans(transcript)
decisions_found = extract_decisions(transcript)
print(f"DEBUG: Found {len(designs)} design proposals", file=sys.stderr)
print(f"DEBUG: Found {len(plans)} parallel plans", file=sys.stderr)
print(f"DEBUG: Found {len(decisions_found)} decision points", file=sys.stderr)
# Write structured files
designs_written = write_design_files(project_root, designs, session_id)
plans_written = write_plan_files(project_root, plans, session_id)
decisions_written = append_decisions(project_root, decisions_found, session_id)
if designs_written or plans_written or decisions_written:
print(
f"DEBUG: ✅ Extracted {designs_written} designs, {plans_written} plans, {decisions_written} decisions",
file=sys.stderr,
)
else:
print(f"DEBUG: No extractable content found", file=sys.stderr)
except Exception as e:
print(f"DEBUG: SessionEnd extraction failed: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# Always continue (don't block session end)
output = {"continue": True}
print(json.dumps(output))
sys.exit(0)
if __name__ == "__main__":
main()

164
hooks/session_end_recorder.py Executable file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""
SessionEnd Recorder - Capture Session Metadata for Next Session
Records lightweight session metadata at session end:
- Session ID and timestamp
- Last commit hash (for git diff baseline)
- Current branch
- Files worked on
- Brief summary
**Token Overhead:** ~100 tokens (write only, no injection)
**Blocking:** No
Hook Protocol:
- Input: JSON via stdin with session data
- Output: JSON via stdout
- IMPORTANT: Never blocks (always {"continue": true})
"""
import json
import subprocess
import sys
from pathlib import Path
from datetime import datetime
import yaml
def get_current_commit_hash() -> str:
"""Get current git commit hash."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
timeout=1,
)
return result.stdout.strip()
except Exception as e:
print(f"DEBUG: Failed to get commit hash: {e}", file=sys.stderr)
return "unknown"
def get_current_branch() -> str:
"""Get current git branch."""
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
timeout=1,
)
return result.stdout.strip()
except Exception as e:
print(f"DEBUG: Failed to get branch: {e}", file=sys.stderr)
return "unknown"
def get_files_changed_in_session(start_hash: str) -> list[str]:
"""Get files changed between start_hash and HEAD."""
try:
# Get files changed since session start
result = subprocess.run(
["git", "diff", "--name-only", f"{start_hash}..HEAD"],
capture_output=True,
text=True,
timeout=2,
)
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
# Also include uncommitted changes
result2 = subprocess.run(
["git", "status", "--short"],
capture_output=True,
text=True,
timeout=1,
)
for line in result2.stdout.split("\n"):
if line.strip():
# Extract filename (format: "M file.py")
parts = line.split(maxsplit=1)
if len(parts) == 2:
files.append(parts[1])
# Deduplicate
return list(set(files))
except Exception as e:
print(f"DEBUG: Failed to get changed files: {e}", file=sys.stderr)
return []
def main():
"""SessionEnd recorder entry point."""
try:
# Read hook data
hook_data = json.loads(sys.stdin.read())
session_id = hook_data.get("session_id", "unknown")
print(f"DEBUG: SessionEnd recorder triggered", file=sys.stderr)
print(f"DEBUG: Session: {session_id}", file=sys.stderr)
# Get git state
commit_hash = get_current_commit_hash()
branch = get_current_branch()
# Load previous session data to calculate files worked on
cache_dir = Path.home() / ".claude" / "plugins" / "contextune" / ".cache"
cache_dir.mkdir(parents=True, exist_ok=True)
last_session_file = cache_dir / "last_session.yaml"
# Get session start commit hash (if tracked)
session_start_hash = commit_hash
if last_session_file.exists():
try:
with open(last_session_file) as f:
prev_session = yaml.safe_load(f) or {}
session_start_hash = prev_session.get("last_commit", commit_hash)
except:
pass
# Get files changed during this session
files_worked_on = get_files_changed_in_session(session_start_hash)
# Create session record
session_record = {
"session_id": session_id,
"ended_at": datetime.now().isoformat() + "Z",
"last_commit": commit_hash,
"branch": branch,
"files_worked_on": files_worked_on[:20], # Limit to 20 files
"file_count": len(files_worked_on),
}
# Write to cache
with open(last_session_file, "w") as f:
yaml.dump(session_record, f, default_flow_style=False)
print(
f"DEBUG: ✅ Recorded session metadata ({len(files_worked_on)} files)",
file=sys.stderr,
)
except Exception as e:
print(f"DEBUG: SessionEnd recorder error: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# Always continue (don't block session end)
response = {"continue": True}
print(json.dumps(response))
if __name__ == "__main__":
main()

105
hooks/session_start.js Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* Contextune SessionStart Hook
*
* 1. Clears old detection state from status line
* 2. Displays available Contextune commands at session start
*
* Uses `feedback` field for ZERO context overhead (0 tokens).
*
* Context Cost: 0 tokens (feedback is UI-only, not added to Claude's context)
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function main() {
try {
// Clear old detection state from observability database
const dbFile = path.join('.contextune', 'observability.db');
try {
if (fs.existsSync(dbFile)) {
// Fast SQLite DELETE query (0.1ms)
execSync(`sqlite3 "${dbFile}" "DELETE FROM current_detection WHERE id = 1"`, {
stdio: 'pipe',
timeout: 1000
});
console.error('DEBUG: Cleared old detection from observability DB');
// Track session start time
const startTime = Date.now() / 1000; // Unix timestamp
const sessionId = `session_${startTime}`;
execSync(`sqlite3 "${dbFile}" "INSERT OR REPLACE INTO sessions (session_id, start_time, total_detections, total_errors) VALUES ('${sessionId}', ${startTime}, 0, 0)"`, {
stdio: 'pipe',
timeout: 1000
});
console.error(`DEBUG: Session started: ${sessionId} at ${new Date(startTime * 1000).toISOString()}`);
}
} catch (err) {
console.error('DEBUG: Failed to clear detection from observability DB:', err.message);
// Non-fatal, continue with session start message
}
// Read SessionStart event from stdin (optional - we don't use it)
// const event = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const contextuneInfo = `
🎯 Contextune Active (v0.5.4) - Natural Language → Slash Commands
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✨ Try It Now (Just Type These):
"research best React state management library"
→ Spawns 3 parallel agents (web + codebase + deps)
→ Results in 1-2 min, ~$0.07
"work on auth, dashboard, and API in parallel"
→ Creates plan + worktrees + parallel execution
→ 30-70% faster than sequential
"what can Contextune do?"
→ Shows full capabilities guide
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📚 Most Used Commands:
/ctx:research <query> Fast answers (3 parallel agents)
/ctx:status Check parallel worktrees progress
/ctx:help Example-first command reference
🔧 Advanced Workflow:
/ctx:plan Create parallel development plan
/ctx:execute Run tasks in parallel worktrees
/ctx:cleanup Clean up completed worktrees
/ctx:configure Setup status bar integration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 Tip: Enable status bar for real-time detection display
Run: /ctx:configure
⚡ Zero context overhead - This message costs 0 tokens!
`.trim();
// Zero-context pattern: feedback shows to user, NOT added to Claude's context
const output = {
continue: true,
feedback: contextuneInfo,
suppressOutput: false // Show in transcript (Ctrl+R)
};
console.log(JSON.stringify(output));
process.exit(0);
} catch (err) {
// Log error but don't block session
console.error('Contextune SessionStart hook error:', err.message);
process.exit(0); // Success exit to continue session
}
}
main();

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* SessionStart Git Context Injector
*
* Injects differential git context at session start:
* - Commits since last session
* - Files changed since last session
* - Current git status
* - Branch information
*
* Token Overhead: ~1-2K tokens (differential only, not full history)
* Blocking: No
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const yaml = require('yaml');
/**
* Load last session metadata
*/
function loadLastSession() {
try {
const cacheDir = path.join(
process.env.HOME,
'.claude',
'plugins',
'contextune',
'.cache'
);
const lastSessionFile = path.join(cacheDir, 'last_session.yaml');
if (!fs.existsSync(lastSessionFile)) {
return null;
}
const content = fs.readFileSync(lastSessionFile, 'utf8');
return yaml.parse(content);
} catch (error) {
console.error('DEBUG: Failed to load last session:', error.message);
return null;
}
}
/**
* Get commits since last session
*/
function getCommitsSinceLastSession(lastCommit, limit = 10) {
try {
const cmd = `git log --oneline ${lastCommit}..HEAD -n ${limit}`;
const output = execSync(cmd, { encoding: 'utf8', timeout: 2000 });
const commits = output.trim().split('\n').filter(line => line);
return commits;
} catch (error) {
console.error('DEBUG: Failed to get commits:', error.message);
return [];
}
}
/**
* Get files changed since last session
*/
function getFilesChanged(lastCommit) {
try {
const cmd = `git diff --name-status ${lastCommit}..HEAD`;
const output = execSync(cmd, { encoding: 'utf8', timeout: 2000 });
const changes = [];
for (const line of output.trim().split('\n')) {
if (!line) continue;
const parts = line.split('\t');
if (parts.length >= 2) {
const status = parts[0];
const file = parts[1];
// Decode status
let changeType = 'modified';
if (status === 'A') changeType = 'added';
else if (status === 'D') changeType = 'deleted';
else if (status === 'M') changeType = 'modified';
else if (status.startsWith('R')) changeType = 'renamed';
changes.push({ file, type: changeType, status });
}
}
return changes;
} catch (error) {
console.error('DEBUG: Failed to get file changes:', error.message);
return [];
}
}
/**
* Get diff statistics
*/
function getDiffStats(lastCommit) {
try {
const cmd = `git diff --shortstat ${lastCommit}..HEAD`;
const output = execSync(cmd, { encoding: 'utf8', timeout: 2000 });
return output.trim();
} catch (error) {
return 'Unable to calculate diff stats';
}
}
/**
* Get current git status
*/
function getCurrentStatus() {
try {
const cmd = 'git status --short';
const output = execSync(cmd, { encoding: 'utf8', timeout: 1000 });
const lines = output.trim().split('\n').filter(line => line);
if (lines.length === 0) {
return { clean: true, uncommitted: 0 };
}
return { clean: false, uncommitted: lines.length, files: lines.slice(0, 5) };
} catch (error) {
return { clean: true, uncommitted: 0 };
}
}
/**
* Calculate time since last session
*/
function getTimeSince(timestamp) {
try {
const lastTime = new Date(timestamp);
const now = new Date();
const diffMs = now - lastTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return 'just now';
} catch (error) {
return 'recently';
}
}
/**
* Generate context summary
*/
function generateContextSummary(lastSession) {
const commits = getCommitsSinceLastSession(lastSession.last_commit);
const filesChanged = getFilesChanged(lastSession.last_commit);
const diffStats = getDiffStats(lastSession.last_commit);
const currentStatus = getCurrentStatus();
const timeSince = getTimeSince(lastSession.ended_at);
// Build summary
let summary = `📋 Git Context Since Last Session (${timeSince})\n\n`;
// Commit activity
if (commits.length > 0) {
summary += `**Git Activity:**\n`;
summary += `- ${commits.length} new commit${commits.length > 1 ? 's' : ''}\n`;
summary += `- ${diffStats}\n`;
summary += `- Branch: ${lastSession.branch}\n\n`;
summary += `**Recent Commits:**\n`;
commits.slice(0, 5).forEach(commit => {
summary += ` ${commit}\n`;
});
if (commits.length > 5) {
summary += ` ... and ${commits.length - 5} more\n`;
}
summary += '\n';
} else {
summary += `**Git Activity:** No commits since last session\n\n`;
}
// File changes
if (filesChanged.length > 0) {
summary += `**Files Changed (${filesChanged.length} total):**\n`;
const byType = { added: [], modified: [], deleted: [], renamed: [] };
filesChanged.forEach(change => {
const list = byType[change.type] || byType.modified;
list.push(change.file);
});
if (byType.added.length > 0) {
summary += ` Added (${byType.added.length}):\n`;
byType.added.slice(0, 3).forEach(f => summary += ` - ${f}\n`);
if (byType.added.length > 3) summary += ` ... and ${byType.added.length - 3} more\n`;
}
if (byType.modified.length > 0) {
summary += ` Modified (${byType.modified.length}):\n`;
byType.modified.slice(0, 3).forEach(f => summary += ` - ${f}\n`);
if (byType.modified.length > 3) summary += ` ... and ${byType.modified.length - 3} more\n`;
}
if (byType.deleted.length > 0) {
summary += ` Deleted (${byType.deleted.length}):\n`;
byType.deleted.slice(0, 3).forEach(f => summary += ` - ${f}\n`);
}
summary += '\n';
}
// Current working directory status
if (!currentStatus.clean) {
summary += `**Current Status:**\n`;
summary += `- ${currentStatus.uncommitted} uncommitted change${currentStatus.uncommitted > 1 ? 's' : ''}\n`;
if (currentStatus.files && currentStatus.files.length > 0) {
summary += `\n**Uncommitted:**\n`;
currentStatus.files.forEach(file => {
summary += ` ${file}\n`;
});
}
summary += '\n';
} else {
summary += `**Current Status:** Working directory clean ✅\n\n`;
}
// Last session context
if (lastSession.files_worked_on && lastSession.files_worked_on.length > 0) {
summary += `**Last Session Work:**\n`;
summary += `- Worked on ${lastSession.file_count} file${lastSession.file_count > 1 ? 's' : ''}\n`;
if (lastSession.files_worked_on.length <= 5) {
lastSession.files_worked_on.forEach(f => {
summary += ` - ${f}\n`;
});
} else {
lastSession.files_worked_on.slice(0, 3).forEach(f => {
summary += ` - ${f}\n`;
});
summary += ` ... and ${lastSession.files_worked_on.length - 3} more\n`;
}
summary += '\n';
}
summary += `---\n\n`;
summary += `**Ready to continue!** Git is synced and tracking all changes.\n`;
return summary;
}
/**
* Main hook entry point
*/
function main() {
try {
// Read stdin
const chunks = [];
process.stdin.on('data', chunk => chunks.push(chunk));
process.stdin.on('end', () => {
try {
const hookData = JSON.parse(Buffer.concat(chunks).toString());
console.error('DEBUG: SessionStart git context injector triggered');
// Load last session
const lastSession = loadLastSession();
if (!lastSession) {
console.error('DEBUG: No previous session found, skipping git context');
// First session or cache cleared
const response = { continue: true };
console.log(JSON.stringify(response));
return;
}
console.error(`DEBUG: Last session: ${lastSession.session_id}`);
console.error(`DEBUG: Last commit: ${lastSession.last_commit}`);
// Generate context summary
const summary = generateContextSummary(lastSession);
console.error(`DEBUG: Generated context summary (${summary.length} chars)`);
// Inject context
const response = {
continue: true,
additionalContext: summary,
suppressOutput: false
};
console.log(JSON.stringify(response));
} catch (error) {
console.error('DEBUG: SessionStart error:', error.message);
// Never block - always continue
const response = { continue: true };
console.log(JSON.stringify(response));
}
});
} catch (error) {
console.error('DEBUG: SessionStart fatal error:', error.message);
// Never block
const response = { continue: true };
console.log(JSON.stringify(response));
}
}
// Handle stdin
if (require.main === module) {
main();
}

206
hooks/tool_cost_tracker.py Executable file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
PostToolUse hook to track actual costs vs. estimates.
Compares routing decisions with actual token usage to:
1. Validate routing decisions
2. Track cumulative costs
3. Calculate actual Haiku vs Sonnet savings
4. Feed data to weekly review
"""
import json
import sys
from pathlib import Path
from typing import Any
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from lib.observability_db import ObservabilityDB
class CostTracker:
"""
Track actual tool costs and compare with routing estimates.
Cost model (per 1K tokens):
- Sonnet input: $0.003
- Sonnet output: $0.015
- Haiku input: $0.00025
- Haiku output: $0.00125
"""
SONNET_INPUT_COST = 0.003
SONNET_OUTPUT_COST = 0.015
HAIKU_INPUT_COST = 0.00025
HAIKU_OUTPUT_COST = 0.00125
def __init__(self):
self.db = ObservabilityDB()
def track_tool_usage(
self,
tool_name: str,
tool_params: dict[str, Any],
result: Any,
model_used: str = "sonnet", # "sonnet" or "haiku"
) -> dict[str, Any]:
"""
Track actual tool usage and calculate costs.
Args:
tool_name: Name of tool used
tool_params: Tool parameters
result: Tool result/output
model_used: Which model executed the tool
Returns:
Cost analysis dictionary
"""
# Estimate tokens from result
estimated_tokens = self._estimate_tokens(tool_name, result)
# Calculate actual cost
if model_used == "sonnet":
input_cost = (estimated_tokens / 1000) * self.SONNET_INPUT_COST
output_cost = (estimated_tokens / 1000) * self.SONNET_OUTPUT_COST
total_cost = input_cost + output_cost
else: # haiku
input_cost = (estimated_tokens / 1000) * self.HAIKU_INPUT_COST
output_cost = (estimated_tokens / 1000) * self.HAIKU_OUTPUT_COST
total_cost = input_cost + output_cost
# Calculate potential savings if wrong model used
if model_used == "sonnet":
haiku_cost = (estimated_tokens / 1000) * (
self.HAIKU_INPUT_COST + self.HAIKU_OUTPUT_COST
)
potential_savings = total_cost - haiku_cost
else:
potential_savings = 0.0 # Already using cheapest model
cost_analysis = {
"tool": tool_name,
"model": model_used,
"estimated_tokens": estimated_tokens,
"actual_cost": total_cost,
"potential_savings": potential_savings,
"efficiency": "optimal" if potential_savings <= 0 else "suboptimal",
}
return cost_analysis
def _estimate_tokens(self, tool_name: str, result: Any) -> int:
"""
Estimate tokens from tool result.
Rough heuristics:
- Read: ~2 tokens per line
- Bash: ~0.5 tokens per char
- Grep: ~1 token per match
- Other: ~100 tokens baseline
"""
if isinstance(result, dict):
result_str = json.dumps(result)
else:
result_str = str(result)
# Tool-specific heuristics
if tool_name == "Read":
line_count = result_str.count("\n")
return line_count * 2
elif tool_name == "Bash":
return len(result_str) // 2
elif tool_name == "Grep":
match_count = result_str.count("\n")
return match_count * 1
else:
# Generic: ~4 chars per token
return len(result_str) // 4
def log_cost_metrics(self, cost_analysis: dict[str, Any]):
"""Log cost metrics to observability database."""
self.db.log_performance_metric(
component="cost_tracker",
operation="tool_cost",
latency_ms=0.0,
metadata={
"tool": cost_analysis["tool"],
"model": cost_analysis["model"],
"tokens": cost_analysis["estimated_tokens"],
"cost": cost_analysis["actual_cost"],
"savings": cost_analysis["potential_savings"],
"efficiency": cost_analysis["efficiency"],
},
)
def main():
"""Main entry point for PostToolUse hook."""
try:
# Read hook input from stdin
hook_data: dict[str, Any] = json.load(sys.stdin)
tool: dict[str, Any] = hook_data.get("tool", {})
tool_name: str = tool.get("name", "")
tool_params: dict[str, Any] = tool.get("parameters", {})
result: Any = hook_data.get("result", {})
# Detect which model was used
# Heuristic: If result is very large but fast, likely Haiku
# For now, assume Sonnet (can be enhanced with actual detection)
model_used = "sonnet"
# Track cost
tracker = CostTracker()
cost_analysis = tracker.track_tool_usage(
tool_name, tool_params, result, model_used
)
# Log to database
tracker.log_cost_metrics(cost_analysis)
# Generate feedback if significant savings possible
if cost_analysis["potential_savings"] > 0.01: # $0.01 threshold
feedback = f"""
💰 **Cost Optimization Opportunity**
Tool: `{tool_name}`
Current cost: ${cost_analysis["actual_cost"]:.4f}
Potential savings: ${cost_analysis["potential_savings"]:.4f}
This operation could be delegated to Haiku for cost efficiency.
""".strip()
output = {"continue": True, "additionalContext": feedback}
else:
output = {"continue": True}
print(json.dumps(output))
except Exception as e:
# Log error but don't block
try:
db = ObservabilityDB()
db.log_error(
component="cost_tracker",
message=str(e),
error_type=type(e).__name__,
)
except Exception:
pass
# Always continue
print(json.dumps({"continue": True}))
if __name__ == "__main__":
main()

289
hooks/tool_router.py Executable file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Intelligent Tool Router for Claude Code.
Routes tool calls to optimal execution strategy:
- Direct Sonnet execution for small operations
- Haiku delegation for large operations
- Parallel Haiku tasks for multi-file operations
Tracks routing decisions via observability database.
"""
import json
import os
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Optional
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from lib.observability_db import ObservabilityDB
class RoutingDecision(Enum):
"""Routing decision types."""
SONNET_DIRECT = "sonnet_direct"
HAIKU_DELEGATE = "haiku_delegate"
HAIKU_PARALLEL = "haiku_parallel"
@dataclass
class RoutingResult:
"""Result of routing decision."""
decision: RoutingDecision
reason: str
estimated_cost_sonnet: float
estimated_cost_haiku: float
savings: float
metadata: Dict[str, Any]
class IntelligentRouter:
"""
Routes tool calls based on operation characteristics.
Thresholds:
- Read: >1000 lines → Haiku
- Bash: >5 commands → Haiku
- Grep: Always fast, keep Sonnet
- Multi-file: >3 files → Parallel Haiku
"""
# Cost estimates (per 1K tokens)
SONNET_INPUT_COST = 0.003 # $3 per million
SONNET_OUTPUT_COST = 0.015 # $15 per million
HAIKU_INPUT_COST = 0.00025 # $0.25 per million
HAIKU_OUTPUT_COST = 0.00125 # $1.25 per million
# Thresholds
READ_LINE_THRESHOLD = 1000
BASH_COMMAND_THRESHOLD = 5
MULTI_FILE_THRESHOLD = 3
def __init__(self):
self.db = ObservabilityDB()
def route_tool_call(self, tool_name: str, tool_params: Dict[str, Any]) -> RoutingResult:
"""
Determine optimal routing for a tool call.
Args:
tool_name: Name of the tool being called
tool_params: Parameters for the tool
Returns:
RoutingResult with decision and cost analysis
"""
if tool_name == "Read":
return self._route_read(tool_params)
elif tool_name == "Bash":
return self._route_bash(tool_params)
elif tool_name == "Grep":
return self._route_grep(tool_params)
elif tool_name == "Glob":
return self._route_glob(tool_params)
else:
# Default: Sonnet handles all other tools
return RoutingResult(
decision=RoutingDecision.SONNET_DIRECT,
reason="Tool not eligible for delegation",
estimated_cost_sonnet=0.0,
estimated_cost_haiku=0.0,
savings=0.0,
metadata={"tool": tool_name}
)
def _route_read(self, params: Dict[str, Any]) -> RoutingResult:
"""Route Read operations."""
file_path = params.get("file_path", "")
# Try to estimate file size
try:
path = Path(file_path)
if path.exists():
line_count = len(path.read_text().split("\n"))
else:
# Assume medium file
line_count = 500
except:
line_count = 500
if line_count > self.READ_LINE_THRESHOLD:
# Large file - delegate to Haiku
estimated_tokens = line_count * 2 # Rough estimate
cost_sonnet = (estimated_tokens / 1000) * self.SONNET_INPUT_COST
cost_haiku = (estimated_tokens / 1000) * self.HAIKU_INPUT_COST
savings = cost_sonnet - cost_haiku
return RoutingResult(
decision=RoutingDecision.HAIKU_DELEGATE,
reason=f"Large file ({line_count} lines) - delegate to Haiku",
estimated_cost_sonnet=cost_sonnet,
estimated_cost_haiku=cost_haiku,
savings=savings,
metadata={
"file": file_path,
"line_count": line_count,
"threshold": self.READ_LINE_THRESHOLD
}
)
else:
# Small file - Sonnet direct
estimated_tokens = line_count * 2
cost_sonnet = (estimated_tokens / 1000) * self.SONNET_INPUT_COST
return RoutingResult(
decision=RoutingDecision.SONNET_DIRECT,
reason=f"Small file ({line_count} lines) - Sonnet optimal",
estimated_cost_sonnet=cost_sonnet,
estimated_cost_haiku=0.0,
savings=0.0,
metadata={
"file": file_path,
"line_count": line_count
}
)
def _route_bash(self, params: Dict[str, Any]) -> RoutingResult:
"""Route Bash operations."""
command = params.get("command", "")
# Count commands (rough heuristic: && or ; separators)
command_count = command.count("&&") + command.count(";") + 1
if command_count > self.BASH_COMMAND_THRESHOLD:
# Multiple commands - delegate to Haiku
estimated_tokens = len(command) * 0.5 # Rough estimate
cost_sonnet = (estimated_tokens / 1000) * self.SONNET_INPUT_COST
cost_haiku = (estimated_tokens / 1000) * self.HAIKU_INPUT_COST
savings = cost_sonnet - cost_haiku
return RoutingResult(
decision=RoutingDecision.HAIKU_DELEGATE,
reason=f"Complex bash ({command_count} commands) - delegate to Haiku",
estimated_cost_sonnet=cost_sonnet,
estimated_cost_haiku=cost_haiku,
savings=savings,
metadata={
"command_preview": command[:100],
"command_count": command_count,
"threshold": self.BASH_COMMAND_THRESHOLD
}
)
else:
# Simple command - Sonnet direct
return RoutingResult(
decision=RoutingDecision.SONNET_DIRECT,
reason=f"Simple bash ({command_count} commands) - Sonnet optimal",
estimated_cost_sonnet=0.01,
estimated_cost_haiku=0.0,
savings=0.0,
metadata={
"command_preview": command[:100],
"command_count": command_count
}
)
def _route_grep(self, params: Dict[str, Any]) -> RoutingResult:
"""Route Grep operations - always fast, keep on Sonnet."""
return RoutingResult(
decision=RoutingDecision.SONNET_DIRECT,
reason="Grep is fast - Sonnet optimal",
estimated_cost_sonnet=0.001,
estimated_cost_haiku=0.0,
savings=0.0,
metadata={"pattern": params.get("pattern", "")}
)
def _route_glob(self, params: Dict[str, Any]) -> RoutingResult:
"""Route Glob operations - always fast, keep on Sonnet."""
return RoutingResult(
decision=RoutingDecision.SONNET_DIRECT,
reason="Glob is fast - Sonnet optimal",
estimated_cost_sonnet=0.001,
estimated_cost_haiku=0.0,
savings=0.0,
metadata={"pattern": params.get("pattern", "")}
)
def main():
"""Main entry point for PreToolUse hook."""
try:
# Read hook input from stdin
hook_data = json.load(sys.stdin)
tool = hook_data.get("tool", {})
tool_name = tool.get("name", "")
tool_params = tool.get("parameters", {})
# Route the tool call
router = IntelligentRouter()
result = router.route_tool_call(tool_name, tool_params)
# Log routing decision to observability database
router.db.log_performance_metric(
component="tool_router",
operation="route_decision",
latency_ms=0.0, # Routing is near-instant
metadata={
"tool": tool_name,
"decision": result.decision.value,
"reason": result.reason,
"estimated_savings": result.savings,
**result.metadata
}
)
# Generate feedback for Claude
if result.decision == RoutingDecision.HAIKU_DELEGATE:
feedback = f"""
⚡ **Routing Suggestion**
Tool: `{tool_name}`
Decision: Delegate to Haiku agent
Reason: {result.reason}
Estimated savings: ${result.savings:.4f}
Consider using the Task tool with subagent_type="general-purpose" for this operation.
""".strip()
# Return suggestion (don't block)
output = {
"continue": True,
"additionalContext": feedback
}
else:
# Direct execution - no feedback needed
output = {"continue": True}
print(json.dumps(output))
except Exception as e:
# Log error but don't block tool execution
try:
db = ObservabilityDB()
db.log_error(
component="tool_router",
error_type=type(e).__name__,
error_message=str(e),
context={"hook": "PreToolUse"}
)
except:
pass
# Always allow tool to continue
print(json.dumps({"continue": True}))
if __name__ == "__main__":
main()

914
hooks/user_prompt_submit.py Executable file
View File

@@ -0,0 +1,914 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "model2vec>=0.3.0",
# "semantic-router>=0.1.0",
# "numpy>=1.24.0",
# "rapidfuzz>=3.0.0"
# ]
# ///
"""
Contextune UserPromptSubmit Hook
Detects slash commands from natural language prompts using 3-tier cascade:
1. Keyword matching (0.02ms, 60% coverage)
2. Model2Vec embeddings (0.2ms, 30% coverage)
3. Semantic Router (50ms, 10% coverage)
Uses Claude Code headless mode for interactive prompt analysis and suggestions.
Hook Protocol:
- Input: JSON via stdin with {"prompt": "...", "session_id": "..."}
- Output: JSON via stdout with {"continue": true, "feedback": "..."}
"""
import json
import subprocess
import sys
import re
from pathlib import Path
from typing import Any
# Add lib directory to Python path
PLUGIN_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PLUGIN_ROOT / "lib"))
# Import matchers (now using RapidFuzz-based keyword matcher v2!)
from keyword_matcher_v2 import IntentMatch, KeywordMatcherV2 as KeywordMatcher
from model2vec_matcher import Model2VecMatcher
from observability_db import ObservabilityDB
from semantic_router_matcher import SemanticRouterMatcher
class ContextuneDetector:
"""
3-tier intent detection cascade.
Uses your existing matchers in order of speed:
1. KeywordMatcher (always fast)
2. Model2VecMatcher (if available)
3. SemanticRouterMatcher (if API key available)
"""
def __init__(self):
self._keyword = None
self._model2vec = None
self._semantic = None
def _get_keyword(self):
if self._keyword is None:
self._keyword = KeywordMatcher()
return self._keyword
def _get_model2vec(self):
if self._model2vec is None:
m = Model2VecMatcher()
self._model2vec = m if m.is_available() else None
return self._model2vec
def _get_semantic(self):
if self._semantic is None:
m = SemanticRouterMatcher()
self._semantic = m if m.is_available() else None
return self._semantic
def detect(self, text: str) -> IntentMatch | None:
"""Detect intent using 3-tier cascade."""
# Tier 1: Keyword (always available)
result = self._get_keyword().match(text)
if result:
return result
# Tier 2: Model2Vec
m2v = self._get_model2vec()
if m2v:
result = m2v.match(text)
if result:
return result
# Tier 3: Semantic Router
sem = self._get_semantic()
if sem:
result = sem.match(text)
if result:
return result
return None
class ClaudeCodeHaikuEngineer:
"""
Uses Claude Code headless mode to analyze prompts and provide interactive suggestions.
Benefits:
- No separate API key needed (uses existing Claude Code auth)
- Integrated billing with Claude Code
- Fast Haiku model for cost optimization
- Interactive blocking mode for user feedback
"""
def __init__(self):
self._claude_available = None
def is_available(self) -> bool:
"""Check if Claude Code CLI is available."""
if self._claude_available is None:
try:
result = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=2
)
self._claude_available = result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
self._claude_available = False
return self._claude_available
def analyze_and_enhance(
self,
prompt: str,
detected_command: str,
confidence: float,
available_commands: list[str],
timeout: int = 30,
) -> dict[str, Any] | None:
"""
Analyze prompt using Claude Code headless mode and suggest enhancements.
Args:
prompt: User's original prompt
detected_command: Command detected by cascade
confidence: Detection confidence (0-1)
available_commands: List of all available commands
timeout: Timeout in seconds
Returns:
Dict with analysis results or None if unavailable/failed
"""
if not self.is_available():
return None
# Build analysis prompt for Haiku
analysis_prompt = f"""You are a prompt enhancement assistant for Contextune, a Claude Code plugin.
USER'S PROMPT: "{prompt}"
DETECTED COMMAND: {detected_command}
DETECTION CONFIDENCE: {confidence:.0%}
AVAILABLE ALTERNATIVES:
{chr(10).join(f"- {cmd}" for cmd in available_commands[:10])}
TASK: Analyze the user's prompt and provide:
1. Whether the detected command is the best match (true/false)
2. Alternative commands if better matches exist
3. A brief, helpful suggestion for the user
RESPONSE FORMAT (JSON):
{{
"is_best_match": true/false,
"alternatives": ["command1", "command2"],
"suggestion": "Brief suggestion text"
}}
Be concise. Focus on actionability."""
try:
# Call Claude Code headless with Haiku model
cmd = [
"claude",
"--model",
"claude-haiku-4-5",
"-p",
analysis_prompt,
"--output-format",
"json",
"--allowedTools",
"", # No tools needed for this analysis
]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout
)
if result.returncode != 0:
print(f"DEBUG: Claude Code error: {result.stderr}", file=sys.stderr)
return None
# Parse Claude's response - it's nested in a wrapper object
claude_response = json.loads(result.stdout)
# Extract the actual result (may be nested in "result" field)
if "result" in claude_response:
result_text = claude_response["result"]
# Result may contain JSON in markdown code blocks
if "```json" in result_text:
# Extract JSON from markdown code block
json_start = result_text.find("```json") + 7
json_end = result_text.find("```", json_start)
result_text = result_text[json_start:json_end].strip()
# Parse the extracted JSON
analysis = json.loads(result_text)
return analysis
else:
# If no "result" field, assume the whole response is the analysis
return claude_response
except subprocess.TimeoutExpired:
print(f"DEBUG: Claude Code timeout after {timeout}s", file=sys.stderr)
return None
except json.JSONDecodeError as e:
print(f"DEBUG: Failed to parse Claude response: {e}", file=sys.stderr)
print(f"DEBUG: Raw output: {result.stdout[:200]}", file=sys.stderr)
return None
except Exception as e:
print(f"DEBUG: Haiku engineer error: {e}", file=sys.stderr)
return None
def should_process(prompt: str) -> bool:
"""Check if prompt needs intent detection."""
if not prompt or not prompt.strip():
return False
# Skip if already a command
if prompt.strip().startswith("/"):
return False
# Skip internal Haiku analysis prompts (prevent feedback loop)
if prompt.startswith("You are a prompt enhancement assistant"):
return False
# Skip if too short
if len(prompt.strip().split()) < 3:
return False
return True
def write_detection_for_statusline(match: IntentMatch, prompt: str):
"""Write detection data to observability DB for status line to read."""
try:
db = ObservabilityDB(".contextune/observability.db")
db.set_detection(
command=match.command,
confidence=match.confidence,
method=match.method,
prompt_preview=prompt[:60] + ("..." if len(prompt) > 60 else ""),
latency_ms=match.latency_ms,
)
# Also log matcher performance
db.log_matcher_performance(match.method, match.latency_ms, success=True)
print(
f"DEBUG: Wrote detection to observability DB: {match.command} ({match.confidence:.0%} {match.method})",
file=sys.stderr,
)
except Exception as e:
# Don't fail hook if observability write fails
print(f"DEBUG: Failed to write to observability DB: {e}", file=sys.stderr)
# Also log the error
try:
db = ObservabilityDB(".contextune/observability.db")
db.log_error("user_prompt_submit", type(e).__name__, str(e))
except:
pass
def clear_detection_statusline():
"""Clear status line detection (no match found)."""
try:
db = ObservabilityDB(".contextune/observability.db")
db.clear_detection()
print("DEBUG: Cleared detection from observability DB", file=sys.stderr)
except Exception as e:
print(
f"DEBUG: Failed to clear detection from observability DB: {e}",
file=sys.stderr,
)
def get_detection_count() -> int:
"""Get total number of detections for progressive tips."""
try:
db = ObservabilityDB(".contextune/observability.db")
stats = db.get_stats()
return stats.get("detections", {}).get("total", 0)
except:
pass
return 0
def increment_detection_count():
"""Increment detection counter for progressive disclosure."""
try:
data_dir = Path.home() / ".claude" / "plugins" / "contextune" / "data"
data_dir.mkdir(parents=True, exist_ok=True)
stats_file = data_dir / "detection_stats.json"
stats = {"total_detections": 0, "by_method": {}, "by_command": {}}
if stats_file.exists():
with open(stats_file) as f:
stats = json.load(f)
stats["total_detections"] = stats.get("total_detections", 0) + 1
with open(stats_file, "w") as f:
json.dump(stats, f, indent=2)
except:
pass # Don't fail hook if stats tracking fails
# Command action descriptions for directive feedback
COMMAND_ACTIONS = {
# Contextune commands
"/ctx:design": "design system architecture with structured workflow",
"/ctx:research": "get fast answers using 3 parallel agents",
"/ctx:plan": "create parallel development plans",
"/ctx:execute": "run tasks in parallel worktrees",
"/ctx:status": "monitor parallel task progress",
"/ctx:cleanup": "clean up completed worktrees",
"/ctx:help": "see example-first command guide",
"/ctx:configure": "enable persistent status bar display",
"/ctx:stats": "see your time & cost savings",
"/ctx:verify": "verify and execute detected command with confirmation",
# Skill-only detections (no commands)
"skill:ctx:performance": "analyze and optimize parallel workflow performance",
"skill:ctx:parallel-expert": "get guidance on parallelizing tasks effectively",
"skill:ctx:help": "discover Contextune features and capabilities",
"skill:ctx:worktree": "troubleshoot git worktree issues and conflicts",
}
# Skill mapping for reliable Claude execution
# Maps slash commands AND skill detections to skill names
# Skills are auto-discovered by Claude Code from: contextune/skills/*/SKILL.md
SKILL_MAPPING = {
# Commands with skills
"/ctx:design": "ctx:architect", # Plugin skill: skills/software-architect
"/ctx:research": "ctx:researcher", # Plugin skill: skills/researcher
# Skills without commands (direct skill suggestions)
"skill:ctx:performance": "ctx:performance",
"skill:ctx:parallel-expert": "ctx:parallel-expert",
"skill:ctx:help": "ctx:help",
"skill:ctx:worktree": "ctx:worktree",
# Note: /ctx:plan and /ctx:execute are commands, not skills
# They execute workflows directly rather than providing guidance
}
def create_skill_augmented_prompt(match: IntentMatch, original_prompt: str) -> str:
"""
Augment prompt with skill suggestion for more reliable execution.
Evidence: Skills are invoked more reliably than slash commands because
they use Claude's native Skill tool (structured, type-safe) vs text expansion.
Args:
match: Detected command and confidence
original_prompt: User's original prompt text
Returns:
Augmented prompt that guides Claude to use skill or command
"""
if match.command in SKILL_MAPPING:
skill_name = SKILL_MAPPING[match.command]
# Strong directive: "You can use your X skill"
return f"{original_prompt}. You can use your {skill_name} skill to help with this task."
else:
# For commands without skills, use directive language
action = COMMAND_ACTIONS.get(match.command, "complete this request")
return f"{original_prompt}. Please use the {match.command} command to {action}."
def get_contextual_tip(match: IntentMatch, detection_count: int) -> str:
"""Generate directive contextual tip based on usage patterns."""
# First-time users (1-3 detections)
if detection_count <= 3:
return "New user? Type `/ctx:help` to see all commands with examples"
# Early users (4-10 detections) - promote status bar
elif detection_count <= 10:
return "Enable persistent detection: Type `/ctx:configure` to set up status bar"
# Experienced users (11-20) - promote advanced features
elif detection_count <= 20:
if match.command.startswith("/ctx:"):
return "Want parallel workflows? Type `/ctx:plan` to work on multiple tasks simultaneously"
return f"Blazing fast: {match.latency_ms:.2f}ms detection. Type `/ctx:stats` to see all metrics"
# Power users (21+) - occasional celebration
else:
if detection_count % 10 == 0: # Every 10th detection
return f"🎉 {detection_count} detections! Type `/ctx:stats` to see your time & cost savings"
return None # No tip for most interactions
def load_available_commands() -> list[str]:
"""Load list of all available commands for Claude Code."""
# Return all commands from COMMAND_ACTIONS
return [cmd for cmd in COMMAND_ACTIONS if cmd.startswith("/")]
def load_available_skills() -> dict[str, str]:
"""
Load all available skills from plugin.
Returns:
Dict mapping directory names to skill names
e.g., {'software-architect': 'ctx:architect'}
"""
skills_dir = PLUGIN_ROOT / 'skills'
if not skills_dir.exists():
return {}
skill_map = {}
for skill_dir in skills_dir.iterdir():
if not skill_dir.is_dir():
continue
skill_file = skill_dir / 'SKILL.md'
if not skill_file.exists():
continue
# Read skill name from frontmatter
try:
with open(skill_file) as f:
content = f.read()
match = re.search(r'^name:\s*(.+)$', content, re.MULTILINE)
if match:
skill_name = match.group(1).strip()
skill_map[skill_dir.name] = skill_name
except:
continue
return skill_map
def detect_skill_invocation(prompt: str) -> tuple[bool, str]:
"""
Detect if user is trying to invoke a skill explicitly.
Returns:
(is_skill_invocation, attempted_skill_name)
"""
patterns = [
r'use (?:the )?([a-z\-:]+) skill',
r'with (?:the )?([a-z\-:]+) skill',
r'([a-z\-:]+) skill to',
r'activate (?:the )?([a-z\-:]+)',
r'invoke (?:the )?([a-z\-:]+)',
]
for pattern in patterns:
match = re.search(pattern, prompt.lower())
if match:
return True, match.group(1)
return False, ''
def find_correct_skill_name(attempted_name: str, skill_map: dict[str, str]) -> tuple[str | None, int]:
"""
Find correct skill name using fuzzy matching.
Args:
attempted_name: What user tried to use
skill_map: Directory → skill name mapping
Returns:
(correct_name, confidence_score)
"""
from rapidfuzz import fuzz
# Exact directory match
if attempted_name in skill_map:
return skill_map[attempted_name], 100
# Exact skill name match (already correct)
if attempted_name in skill_map.values():
return attempted_name, 100
# Fuzzy match
best_match = None
best_score = 0
for directory, skill_name in skill_map.items():
# Directory name
score = fuzz.ratio(attempted_name, directory)
if score > best_score and score > 70:
best_score = score
best_match = skill_name
# Skill name
score = fuzz.ratio(attempted_name, skill_name)
if score > best_score and score > 70:
best_score = score
best_match = skill_name
# Skill name without prefix
skill_base = skill_name.replace('ctx:', '')
score = fuzz.ratio(attempted_name, skill_base)
if score > best_score and score > 70:
best_score = score
best_match = skill_name
return best_match, best_score if best_match else 0
def format_suggestion(match: IntentMatch, detection_count: int = 0) -> str:
"""Format detection with directive, actionable phrasing."""
# Get action description
action = COMMAND_ACTIONS.get(match.command, "execute this command")
# Build directive message
confidence_pct = int(match.confidence * 100)
# Primary directive message
base_msg = (
f"💡 Type `{match.command}` to {action} ({confidence_pct}% {match.method}"
)
# Add latency if fast (show performance)
if match.latency_ms < 1.0:
base_msg += f", {match.latency_ms:.2f}ms"
base_msg += ")"
# Get contextual tip
tip = get_contextual_tip(match, detection_count)
if tip:
return f"{base_msg}\n💡 {tip}"
return base_msg
def format_interactive_suggestion(
match: IntentMatch, analysis: dict[str, Any] | None, detection_count: int = 0
) -> str:
"""
Format interactive suggestion with Haiku analysis.
Args:
match: Detected command match
analysis: Haiku analysis results (optional)
detection_count: Total detections for contextual tips
Returns:
Formatted suggestion message
"""
# Get action description
action = COMMAND_ACTIONS.get(match.command, "execute this command")
confidence_pct = int(match.confidence * 100)
# Base detection message
base_msg = f"🎯 Detected: `{match.command}` ({confidence_pct}% via {match.method})"
# Add latency if fast
if match.latency_ms < 1.0:
base_msg += f"\n⚡ Detection speed: {match.latency_ms:.2f}ms"
# Add Haiku analysis if available
if analysis:
if not analysis.get("is_best_match", True):
alternatives = analysis.get("alternatives", [])
if alternatives:
base_msg += "\n\n💡 Better alternatives:"
for alt in alternatives[:3]:
alt_action = COMMAND_ACTIONS.get(alt, "execute this command")
base_msg += f"\n • `{alt}` - {alt_action}"
suggestion = analysis.get("suggestion")
if suggestion:
base_msg += f"\n\n💬 Suggestion: {suggestion}"
else:
# Fallback without analysis
base_msg += f"\n\n📝 Action: Type `{match.command}` to {action}"
# Get contextual tip
tip = get_contextual_tip(match, detection_count)
if tip:
base_msg += f"\n\n💡 Tip: {tip}"
return base_msg
def detect_git_workflow(prompt: str) -> tuple[bool, str | None]:
"""
Detect git workflow patterns in natural language.
Returns: (is_git_workflow, script_command)
"""
prompt_lower = prompt.lower()
# Git workflow patterns
patterns = [
# Commit and push
(r'\b(commit|save|push|commit.*push|push.*commit)\b', '/ctx:git-commit'),
# Create PR
(r'\b(create.*pr|make.*pr|open.*pr|pull.*request)\b', None), # TODO: /ctx:git-pr
# Merge
(r'\b(merge|merge.*branch)\b', None), # TODO: /ctx:git-merge
]
for pattern, command in patterns:
if re.search(pattern, prompt_lower):
print(f"DEBUG: Git workflow detected: {pattern}{command}", file=sys.stderr)
return True, command
return False, None
def main():
"""Hook entry point."""
try:
# Read hook event from stdin
event_json = sys.stdin.read()
event = json.loads(event_json)
prompt = event.get("prompt", "")
# DEBUG: Log what we received
print(
f"DEBUG: Contextune hook triggered with prompt: '{prompt}'", file=sys.stderr
)
# Check if we should process
if not should_process(prompt):
print("DEBUG: Skipping prompt (should_process=False)", file=sys.stderr)
# Pass through unchanged
response = {"continue": True, "suppressOutput": True}
print(json.dumps(response))
return
print("DEBUG: Processing prompt (should_process=True)", file=sys.stderr)
# GIT WORKFLOW DETECTION: Check if user wants git workflow
is_git_workflow, git_command = detect_git_workflow(prompt)
if is_git_workflow and git_command:
print(f"DEBUG: Git workflow detected, suggesting: {git_command}", file=sys.stderr)
feedback = f"""⚡ Git Workflow Detected
Your request matches a git workflow pattern.
💡 **Recommended:** Use the deterministic slash command instead:
`{git_command}`
**Benefits:**
- ✅ 93-97% token reduction (~$0.002 vs ~$0.037-0.086)
- ✅ Single command execution
- ✅ Deterministic, tested workflow
- ✅ Auto-detects remote
- ✅ Proper error handling
**Example:**
```
{git_command}
```
Continuing with your original prompt, but consider using the slash command for efficiency."""
response = {
"continue": True,
"additionalContext": feedback,
"suppressOutput": False
}
print(json.dumps(response))
return
# SKILL DETECTION: Check if user is trying to invoke a skill
is_skill_invocation, attempted_skill = detect_skill_invocation(prompt)
if is_skill_invocation:
print(f"DEBUG: Skill invocation detected: '{attempted_skill}'", file=sys.stderr)
# Load available skills
skill_map = load_available_skills()
print(f"DEBUG: Loaded {len(skill_map)} skills", file=sys.stderr)
# Find correct skill name
correct_skill, confidence = find_correct_skill_name(attempted_skill, skill_map)
if correct_skill and confidence > 70:
if attempted_skill != correct_skill:
# Suggest correction
suggestion = f"""💡 Skill Name Correction
Detected: Trying to use '{attempted_skill}' skill
Correct name: '{correct_skill}' (match confidence: {confidence}%)
Use: {correct_skill}
Available skills:
{chr(10).join(f'{name} (directory: {dir})' for dir, name in skill_map.items())}
"""
print(f"DEBUG: Suggesting skill name correction: {attempted_skill}{correct_skill}", file=sys.stderr)
response = {
"continue": True,
"additionalContext": suggestion,
"suppressOutput": False
}
print(json.dumps(response))
return
else:
print(f"DEBUG: Skill name already correct: {correct_skill}", file=sys.stderr)
else:
print(f"DEBUG: No matching skill found for '{attempted_skill}'", file=sys.stderr)
# Initialize detector
detector = ContextuneDetector()
# Detect intent
match = detector.detect(prompt)
print(f"DEBUG: Detection result: {match}", file=sys.stderr)
if match is None or match.confidence < 0.7:
print(
"DEBUG: No match or low confidence, passing through", file=sys.stderr
)
# Clear status line detection (no match)
clear_detection_statusline()
# No match or low confidence - pass through
response = {"continue": True, "suppressOutput": True}
print(json.dumps(response))
return
# Write detection for status line
write_detection_for_statusline(match, prompt)
# Get current detection count for progressive tips
detection_count = get_detection_count()
# Increment counter
increment_detection_count()
print(
f"DEBUG: Command detected (detection #{detection_count + 1})",
file=sys.stderr,
)
# Initialize Haiku engineer for interactive analysis
engineer = ClaudeCodeHaikuEngineer()
haiku_analysis = None
haiku_latency_ms = 0.0
# Selective triggering: Only run Haiku for low-confidence or fuzzy/semantic matches
# High-confidence exact matches (0.95+) are reliable and don't need Haiku validation
should_run_haiku = match.confidence < 0.95 or match.method in ['fuzzy', 'semantic']
if should_run_haiku:
print(f"DEBUG: Triggering Haiku analysis (confidence={match.confidence:.2f}, method={match.method})", file=sys.stderr)
else:
print(f"DEBUG: Skipping Haiku analysis (high-confidence {match.method} match: {match.confidence:.2f})", file=sys.stderr)
# Try to get Haiku analysis for better suggestions (only if needed)
if should_run_haiku and engineer.is_available():
print(f"DEBUG: Running Haiku analysis...", file=sys.stderr)
available_commands = load_available_commands()
# Track Haiku analysis latency
import time
haiku_start = time.perf_counter()
haiku_analysis = engineer.analyze_and_enhance(
prompt=prompt,
detected_command=match.command,
confidence=match.confidence,
available_commands=available_commands,
timeout=30,
)
haiku_latency_ms = (time.perf_counter() - haiku_start) * 1000
if haiku_analysis:
print(
f"DEBUG: Haiku analysis: {json.dumps(haiku_analysis)}",
file=sys.stderr,
)
else:
print(f"DEBUG: Haiku analysis failed or timed out", file=sys.stderr)
elif not should_run_haiku:
print(f"DEBUG: Haiku analysis skipped (selective triggering)", file=sys.stderr)
else:
print(
"DEBUG: Claude Code CLI not available, skipping Haiku analysis",
file=sys.stderr,
)
# AUGMENT MODE: Modify prompt with skill/command suggestion for reliability
print("DEBUG: Augmenting prompt for Claude", file=sys.stderr)
# Create augmented prompt with skill suggestion
augmented_prompt = create_skill_augmented_prompt(match, prompt)
# Format interactive suggestion with Haiku analysis (if available)
interactive_msg = format_interactive_suggestion(
match, haiku_analysis, detection_count
)
# Determine the best command to use
# If Haiku suggests alternatives and it's not the best match, use the first alternative
best_command = match.command
correction_accepted = False
if haiku_analysis and not haiku_analysis.get("is_best_match", True):
alternatives = haiku_analysis.get("alternatives", [])
if alternatives:
best_command = alternatives[0]
correction_accepted = True
print(
f"DEBUG: Haiku suggests using {best_command} instead of {match.command}",
file=sys.stderr,
)
# Log correction to observability DB
if haiku_analysis:
try:
db = ObservabilityDB(".contextune/observability.db")
# Estimate token counts (rough approximation)
# Haiku prompt is ~150 tokens + command list + user prompt
prompt_tokens = (
150 + len(prompt.split()) + len(load_available_commands()) * 5
)
# Response is typically ~50-100 tokens
completion_tokens = (
50 + len(str(haiku_analysis.get("suggestion", ""))) // 4
)
db.log_correction(
original_command=match.command,
corrected_command=best_command,
original_confidence=match.confidence,
correction_accepted=correction_accepted,
model_name="haiku-4-5",
reasoning=haiku_analysis.get("suggestion", ""),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=haiku_latency_ms,
session_id=event.get("session_id", ""),
prompt_preview=prompt[:100],
)
print(
f"DEBUG: Logged correction to observability DB (accepted={correction_accepted})",
file=sys.stderr,
)
except Exception as e:
print(f"DEBUG: Failed to log correction: {e}", file=sys.stderr)
# Create augmented prompt with the best command (potentially corrected by Haiku)
if best_command != match.command:
# Use Haiku's suggested command
action = COMMAND_ACTIONS.get(best_command, "complete this request")
if best_command in SKILL_MAPPING:
skill_name = SKILL_MAPPING[best_command]
augmented_prompt = f"{prompt}. You can use your {skill_name} skill to help with this task."
else:
augmented_prompt = (
f"{prompt}. Please use the {best_command} command to {action}."
)
else:
# Use original detection
augmented_prompt = create_skill_augmented_prompt(match, prompt)
# SUGGEST-ONLY MODE: Show detection but let user decide
# Don't auto-execute - just provide helpful suggestion
print(
f"DEBUG: Using suggest-only mode - showing suggestion for {best_command}",
file=sys.stderr,
)
response = {
"continue": True,
"additionalContext": interactive_msg,
"suppressOutput": False
}
print(f"DEBUG: Response: {json.dumps(response)}", file=sys.stderr)
print(json.dumps(response))
except Exception as e:
# Log error but don't block Claude
import traceback
print(f"Contextune error: {e}", file=sys.stderr)
print(f"DEBUG: Traceback: {traceback.format_exc()}", file=sys.stderr)
response = {"continue": True, "suppressOutput": True}
print(json.dumps(response))
if __name__ == "__main__":
main()

281
hooks/version_checker.js Executable file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* Contextune Version Checker
*
* Checks for plugin updates by comparing local version with remote version.
* Runs on SessionStart hook to notify users of available updates.
*
* Features:
* - Fetches latest version from GitHub
* - Caches check (once per day)
* - Non-blocking (never fails session start)
* - Friendly upgrade notifications
* - Tracks check history in observability DB
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
// Configuration
const GITHUB_OWNER = 'Shakes-tzd';
const GITHUB_REPO = 'contextune';
const PLUGIN_JSON_URL = `https://raw.githubusercontent.com/${GITHUB_OWNER}/${GITHUB_REPO}/master/.claude-plugin/plugin.json`;
const CHECK_INTERVAL_HOURS = 24; // Check once per day
const CACHE_FILE = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'plugins', 'contextune', 'data', 'version_cache.json');
/**
* Get current installed version
*/
function getCurrentVersion() {
try {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
return null;
}
const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json');
const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf-8'));
return pluginJson.version;
} catch (error) {
console.error(`Version check error (local): ${error.message}`);
return null;
}
}
/**
* Fetch latest version from GitHub
*/
function fetchLatestVersion() {
return new Promise((resolve, reject) => {
const request = https.get(PLUGIN_JSON_URL, { timeout: 3000 }, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
try {
if (response.statusCode === 200) {
const pluginJson = JSON.parse(data);
resolve(pluginJson.version);
} else {
reject(new Error(`HTTP ${response.statusCode}`));
}
} catch (error) {
reject(error);
}
});
});
request.on('error', reject);
request.on('timeout', () => {
request.destroy();
reject(new Error('Request timeout'));
});
});
}
/**
* Compare version strings (semver-like)
*/
function compareVersions(current, latest) {
const currentParts = current.split('.').map(Number);
const latestParts = latest.split('.').map(Number);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const currentPart = currentParts[i] || 0;
const latestPart = latestParts[i] || 0;
if (latestPart > currentPart) return 1; // Update available
if (latestPart < currentPart) return -1; // Current is newer (dev version)
}
return 0; // Versions are equal
}
/**
* Get cached version check result
*/
function getCachedCheck() {
try {
if (!fs.existsSync(CACHE_FILE)) {
return null;
}
const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
const cacheAge = Date.now() - cache.timestamp;
const cacheValid = cacheAge < CHECK_INTERVAL_HOURS * 60 * 60 * 1000;
return cacheValid ? cache : null;
} catch (error) {
return null;
}
}
/**
* Save version check result to cache
*/
function saveCachedCheck(currentVersion, latestVersion, updateAvailable) {
try {
const cacheDir = path.dirname(CACHE_FILE);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const cache = {
timestamp: Date.now(),
currentVersion,
latestVersion,
updateAvailable,
lastCheck: new Date().toISOString()
};
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
} catch (error) {
// Silent fail - caching is not critical
}
}
/**
* Record version check in observability database
*/
function recordVersionCheck(currentVersion, latestVersion, updateAvailable) {
try {
const dbFile = path.join(process.env.CLAUDE_PLUGIN_ROOT || '', '.contextune', 'observability.db');
if (!fs.existsSync(dbFile)) {
return; // DB doesn't exist yet
}
const query = `
INSERT INTO version_checks (check_time, current_version, latest_version, update_available)
VALUES (${Date.now() / 1000}, '${currentVersion}', '${latestVersion}', ${updateAvailable ? 1 : 0})
`;
execSync(`sqlite3 "${dbFile}" "${query}"`, {
stdio: 'pipe',
timeout: 1000
});
} catch (error) {
// Silent fail - observability is not critical
}
}
/**
* Generate update notification message
*/
function generateUpdateMessage(currentVersion, latestVersion) {
return `
╭─────────────────────────────────────────────────╮
│ 🎉 Contextune Update Available! │
├─────────────────────────────────────────────────┤
│ │
│ Current: v${currentVersion.padEnd(10)} → Latest: v${latestVersion}
│ │
│ 📦 What's New: │
│ • Performance improvements │
│ • Bug fixes and enhancements │
│ • See full changelog on GitHub │
│ │
│ 🔄 To Update: │
│ /plugin update contextune │
│ │
│ 📚 Release Notes: │
│ github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases │
│ │
╰─────────────────────────────────────────────────╯
💡 Tip: Keep Contextune updated for the latest features and fixes!
`;
}
/**
* Main version check logic
*/
async function checkVersion() {
try {
// Get current version
const currentVersion = getCurrentVersion();
if (!currentVersion) {
console.error('Could not determine current version');
return;
}
// Check cache first
const cached = getCachedCheck();
if (cached) {
if (cached.updateAvailable) {
console.log(generateUpdateMessage(cached.currentVersion, cached.latestVersion));
}
return;
}
// Fetch latest version from GitHub
const latestVersion = await fetchLatestVersion();
// Compare versions
const comparison = compareVersions(currentVersion, latestVersion);
const updateAvailable = comparison > 0;
// Save to cache
saveCachedCheck(currentVersion, latestVersion, updateAvailable);
// Record in observability DB
recordVersionCheck(currentVersion, latestVersion, updateAvailable);
// Show notification if update available
if (updateAvailable) {
console.log(generateUpdateMessage(currentVersion, latestVersion));
} else {
console.error(`Contextune v${currentVersion} (latest)`);
}
} catch (error) {
// Silent fail - version check should never block session start
console.error(`Version check skipped: ${error.message}`);
}
}
/**
* Initialize version checks table in observability DB
*/
function initializeDatabase() {
try {
const dbFile = path.join(process.env.CLAUDE_PLUGIN_ROOT || '', '.contextune', 'observability.db');
if (!fs.existsSync(dbFile)) {
return; // DB doesn't exist yet, will be created by other hooks
}
const createTableQuery = `
CREATE TABLE IF NOT EXISTS version_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
check_time REAL NOT NULL,
current_version TEXT NOT NULL,
latest_version TEXT NOT NULL,
update_available INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
execSync(`sqlite3 "${dbFile}" "${createTableQuery}"`, {
stdio: 'pipe',
timeout: 2000
});
} catch (error) {
// Silent fail
}
}
// Run version check
if (require.main === module) {
initializeDatabase();
checkVersion().catch(err => {
console.error(`Version check failed: ${err.message}`);
});
}
module.exports = { checkVersion, compareVersions };