mirror of
https://github.com/kochetkov-ma/claude-brewcode.git
synced 2026-09-14 20:16:41 +08:00
255 lines
9.2 KiB
JavaScript
255 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* PreToolUse hook for Task tool
|
||
* - Injects grepai reminder for ALL agents (when .grepai/ exists)
|
||
* - Injects ## K knowledge into sub-agent prompts (brewcode only)
|
||
* - Injects v3 task context (phase reminder + paths) when phases/ exists
|
||
*/
|
||
import {
|
||
readStdin,
|
||
output,
|
||
getKnowledgePath,
|
||
checkLock,
|
||
isSystemAgent,
|
||
loadConfig,
|
||
log,
|
||
getActiveMode
|
||
} from './lib/utils.mjs';
|
||
import { readKnowledge, compressKnowledge } from './lib/knowledge.mjs';
|
||
import { existsSync, readFileSync } from 'fs';
|
||
import { join, dirname } from 'path';
|
||
|
||
const GREPAI_REMINDER = 'grepai: USE grepai_search FIRST for code exploration';
|
||
|
||
/**
|
||
* Parse section content from string using tag boundaries
|
||
* Pure function - easily testable without file I/O
|
||
* @param {string} content - File content
|
||
* @param {string} tag - Tag name (ALL, DEV, TEST, REVIEW)
|
||
* @returns {string} Section content or empty string
|
||
*/
|
||
function parseSectionFromContent(content, tag) {
|
||
if (!content || !tag) return '';
|
||
|
||
const startTag = `<!-- ${tag} -->`;
|
||
const endTag = `<!-- /${tag} -->`;
|
||
|
||
const startIdx = content.indexOf(startTag);
|
||
if (startIdx === -1) return '';
|
||
|
||
const endIdx = content.indexOf(endTag, startIdx);
|
||
if (endIdx === -1) return '';
|
||
|
||
const section = content.substring(startIdx + startTag.length, endIdx);
|
||
|
||
return section
|
||
.split('\n')
|
||
.filter(line => !/^\s*<!--.*-->\s*$/s.test(line))
|
||
.join('\n')
|
||
.trim();
|
||
}
|
||
|
||
async function main() {
|
||
let cwd = null;
|
||
let session_id = null;
|
||
|
||
try {
|
||
cwd = process.cwd();
|
||
const input = await readStdin();
|
||
session_id = input.session_id;
|
||
cwd = input.cwd || cwd;
|
||
const tool_input = input.tool_input;
|
||
|
||
// Only process Task tool calls
|
||
if (!tool_input) {
|
||
output({});
|
||
return;
|
||
}
|
||
|
||
const subagentType = tool_input.subagent_type;
|
||
|
||
// Skip if no subagent type
|
||
if (!subagentType) {
|
||
output({});
|
||
return;
|
||
}
|
||
|
||
// Check grepai availability (for ALL agents including system agents)
|
||
const grepaiDir = join(cwd, '.grepai');
|
||
const hasGrepai = existsSync(grepaiDir) && existsSync(join(grepaiDir, 'index.gob'));
|
||
|
||
let updatedPrompt = tool_input.prompt || '';
|
||
let modified = false;
|
||
|
||
// permission_mode: DOC-VERIFIED common field (2.1.195). Presence-guarded; falsy when absent -> current behavior.
|
||
const planMode = input.permission_mode === 'plan';
|
||
|
||
// 0.0 Effort-level prefix (CC 2.1.115+). Idempotent: skip if already present.
|
||
// NOTE: effort.level is NOT in HOOKS-REFERENCE.md (2.1.195). Presence-guarded existing read; do not expand to other hooks.
|
||
const effortLevel = input.effort?.level;
|
||
if (effortLevel === 'low' && !updatedPrompt.includes('[EFFORT:')) {
|
||
updatedPrompt = `[EFFORT: low | MODE: terse-light]\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injected EFFORT=low prefix for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
// 0. Inject BC_PLUGIN_ROOT for ALL agents (first injection)
|
||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || '';
|
||
if (pluginRoot) {
|
||
updatedPrompt = `BC_PLUGIN_ROOT=${pluginRoot}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injected BC_PLUGIN_ROOT for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
const pluginData = process.env.CLAUDE_PLUGIN_DATA || '';
|
||
if (pluginData) {
|
||
updatedPrompt = `BC_PLUGIN_DATA=${pluginData}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injected BC_PLUGIN_DATA for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
// 0.5 Inject mode instructions for ALL agents
|
||
const activeMode = getActiveMode(cwd, session_id);
|
||
if (activeMode) {
|
||
updatedPrompt = `[MODE: ${activeMode.name}] ${activeMode.instructions}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injected mode "${activeMode.name}" for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
// 0.7 Inject SID for all agents when teams exist (any agent may participate in teams)
|
||
const teamsDir = join(cwd, '.claude', 'teams');
|
||
if (existsSync(teamsDir) && typeof session_id === 'string' && session_id.length >= 8) {
|
||
const sid = session_id.slice(0, 8);
|
||
updatedPrompt = `SID=${sid}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injected SID=${sid} for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
// 1. Inject grepai reminder for ALL agents (including Explore, Plan, etc.)
|
||
if (hasGrepai) {
|
||
updatedPrompt = `${GREPAI_REMINDER}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `grepai reminder for ${subagentType}`, cwd, session_id);
|
||
}
|
||
|
||
// 2. Inject knowledge for NON-system agents only (skip coordinator, Explore, etc.)
|
||
const isSystem = isSystemAgent(subagentType, cwd);
|
||
if (!isSystem) {
|
||
const config = loadConfig(cwd);
|
||
const lock = checkLock(cwd, session_id);
|
||
|
||
// In plan mode the parent is designing, not executing -> anti-pattern KNOWLEDGE is noise. Skip injection.
|
||
// Guard: planMode falsy when permission_mode absent or any non-'plan' value -> current injection behavior.
|
||
if (lock && lock.task_path && !planMode) {
|
||
const knowledgePath = getKnowledgePath(join(cwd, lock.task_path));
|
||
const entries = readKnowledge(knowledgePath);
|
||
|
||
if (entries.length) {
|
||
let knowledge = compressKnowledge(entries, config.knowledge.maxTokens);
|
||
|
||
if (knowledge) {
|
||
// Empirical: prepending `❌ CRITICAL:` emphasizes anti-pattern lines
|
||
// for the autocompact summarizer LLM. Not a documented CC contract —
|
||
// best-effort emphasis, not guaranteed retention.
|
||
// Only affects lines starting with ❌; ✅ and ℹ️ lines pass through unchanged.
|
||
knowledge = knowledge
|
||
.split('\n')
|
||
.map(line => (line.startsWith('❌ ') && !line.startsWith('❌ CRITICAL:'))
|
||
? `❌ CRITICAL: ${line.slice(2)}`
|
||
: line)
|
||
.join('\n');
|
||
|
||
updatedPrompt = `${knowledge}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('info', '[pre-task]', `Injecting knowledge for ${subagentType} (${entries.length} entries)`, cwd, session_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Inject v3 task context (phase reminder + paths) for non-system agents
|
||
if (lock && lock.task_path) {
|
||
const taskDir = dirname(join(cwd, lock.task_path));
|
||
const phasesDir = join(taskDir, 'phases');
|
||
|
||
if (existsSync(phasesDir)) {
|
||
const artifactsDir = join(taskDir, 'artifacts');
|
||
const taskContext = [
|
||
'## Task Context',
|
||
`Task dir: ${taskDir}`,
|
||
`Artifacts: ${artifactsDir}`,
|
||
'',
|
||
'> ⛔ READ the phases/ file referenced in your task description FIRST before doing any work.'
|
||
].join('\n');
|
||
|
||
updatedPrompt = `${taskContext}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injecting v3 task context for ${subagentType}`, cwd, session_id);
|
||
}
|
||
}
|
||
|
||
// 4. Inject constraints for non-system agents
|
||
if (config.constraints?.enabled !== false && lock && lock.task_path) {
|
||
const taskPath = join(cwd, lock.task_path);
|
||
let taskContent = null;
|
||
try {
|
||
if (existsSync(taskPath)) {
|
||
taskContent = readFileSync(taskPath, 'utf8');
|
||
}
|
||
} catch {}
|
||
|
||
if (taskContent) {
|
||
// ALL constraints apply to every non-system agent
|
||
const allConstraints = parseSectionFromContent(taskContent, 'ALL');
|
||
|
||
// Detect role for role-specific constraints
|
||
const name = subagentType.toLowerCase();
|
||
let role = null;
|
||
if (/\b(?:test(?:er)?|qa|sdet)\b/.test(name)) {
|
||
role = 'TEST';
|
||
} else if (/\b(?:review(?:er)?|check(?:er)?|audit(?:or)?)\b/.test(name)) {
|
||
role = 'REVIEW';
|
||
} else if (/\b(?:dev(?:eloper)?|implement(?:er)?|cod(?:er|ing)|engineer|architect|build(?:er)?|fix(?:er)?)\b/.test(name)) {
|
||
role = 'DEV';
|
||
}
|
||
|
||
const roleConstraints = role ? parseSectionFromContent(taskContent, role) : '';
|
||
|
||
if (allConstraints || roleConstraints) {
|
||
const constraintLines = [allConstraints, roleConstraints]
|
||
.filter(c => c)
|
||
.join('\n');
|
||
|
||
const constraintInjection = `## Task Constraints\n${constraintLines}`;
|
||
updatedPrompt = `${constraintInjection}\n\n${updatedPrompt}`;
|
||
modified = true;
|
||
log('debug', '[pre-task]', `Injecting ${role || 'ALL'} constraints for ${subagentType}`, cwd, session_id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Output result - updatedInput MUST be inside hookSpecificOutput per Claude Code docs
|
||
// Note: systemMessage removed - logs go to brewcode.log only, not UI
|
||
if (modified) {
|
||
output({
|
||
hookSpecificOutput: {
|
||
hookEventName: 'PreToolUse',
|
||
permissionDecision: 'allow',
|
||
updatedInput: {
|
||
...tool_input,
|
||
prompt: updatedPrompt
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
output({});
|
||
}
|
||
} catch (error) {
|
||
// On error, pass through without modification
|
||
log('error', '[pre-task]', `Error: ${error.message}`, cwd, session_id);
|
||
output({});
|
||
}
|
||
}
|
||
|
||
main();
|