Initial commit
This commit is contained in:
109
hooks/compact_start.js
Executable file
109
hooks/compact_start.js
Executable 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
368
hooks/context_injector.js
Executable 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
363
hooks/context_preserver.py
Executable 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
164
hooks/context_restorer.js
Executable 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
274
hooks/git_workflow_detector.py
Executable 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
130
hooks/hooks.json
Normal 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
191
hooks/pre_tool_use_git_advisor.py
Executable 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
230
hooks/pre_tool_use_state_sync.py
Executable 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
819
hooks/session_end_extractor.py
Executable 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
164
hooks/session_end_recorder.py
Executable 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
105
hooks/session_start.js
Executable 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();
|
||||
317
hooks/session_start_git_context.js
Executable file
317
hooks/session_start_git_context.js
Executable 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
206
hooks/tool_cost_tracker.py
Executable 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
289
hooks/tool_router.py
Executable 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
914
hooks/user_prompt_submit.py
Executable 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
281
hooks/version_checker.js
Executable 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 };
|
||||
Reference in New Issue
Block a user