369 lines
9.2 KiB
JavaScript
Executable File
369 lines
9.2 KiB
JavaScript
Executable File
#!/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
|
||
};
|