From 5ce9e584ae9f9633909ce6ea4fbf6dfcb8734b8a Mon Sep 17 00:00:00 2001 From: sumeyaaaa Date: Sat, 21 Feb 2026 13:57:30 +0300 Subject: [PATCH] chore: commit src/hooks/HookEngine.ts --- src/hooks/HookEngine.ts | 810 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 779 insertions(+), 31 deletions(-) diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts index e3c2d6bf1b..38dbe9fe44 100644 --- a/src/hooks/HookEngine.ts +++ b/src/hooks/HookEngine.ts @@ -7,12 +7,27 @@ import * as path from "path" import * as fs from "fs/promises" import { execSync } from "child_process" +/** + * Standardized error format for autonomous recovery (Phase 2 requirement) + * LLM can parse this JSON and self-correct without crashing + */ +export interface HookError { + error_type: string + message: string + details?: { + [key: string]: any + } + recoverable: boolean + suggested_action?: string +} + /** * Hook execution result */ export interface HookResult { shouldProceed: boolean errorMessage?: string + structuredError?: HookError // Phase 2: Structured error for autonomous recovery injectedContext?: string } @@ -22,6 +37,10 @@ export interface HookResult { */ export class HookEngine { private dataModel: OrchestrationDataModel + private initialized = false + // Phase 4: Track file hashes for optimistic locking + private fileHashCache: Map = new Map() + private readonly HASH_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes constructor(workspaceRoot: string) { this.dataModel = new OrchestrationDataModel(workspaceRoot) @@ -72,16 +91,233 @@ export class HookEngine { * Initialize orchestration directory */ async initialize(): Promise { + if (this.initialized) return await this.dataModel.initialize() + this.initialized = true + } + + /** + * Extract the primary target file path for a tool call (best-effort). + * This enables consistent scope checks across all file-mutating tools. + */ + private extractTargetFilePath(toolName: ToolName, toolUse: ToolUse): string | undefined { + const nativeArgs = (toolUse as any).nativeArgs + const params = toolUse.params as any + + // Direct path/file_path parameters + const direct = (nativeArgs?.path || nativeArgs?.file_path || params?.path || params?.file_path) as + | string + | undefined + if (direct) return direct + + // apply_patch: infer from patch content + const patchContent = nativeArgs?.patch || params?.patch + if (patchContent && typeof patchContent === "string") { + // Unified diff: --- a/foo or +++ b/foo + const unifiedDiffMatch = patchContent.match(/^(?:---|\+\+\+)\s+(?:a\/|b\/)?(.+?)(?:\s|$)/m) + if (unifiedDiffMatch?.[1]) return unifiedDiffMatch[1].trim() + + // Custom format: "Update File: foo" + const fileMatches = patchContent.match(/(?:Update|Create|Delete)\s+File:\s*(.+)/gm) + if (fileMatches?.length) { + const match = fileMatches[0].match(/(?:Update|Create|Delete)\s+File:\s*(.+)/) + if (match?.[1]) return match[1].trim() + } + } + + return undefined + } + + /** + * Create standardized error for autonomous recovery (Phase 2 requirement) + */ + private createStructuredError( + errorType: string, + message: string, + details?: Record, + recoverable: boolean = true, + suggestedAction?: string, + ): HookError { + return { + error_type: errorType, + message, + details, + recoverable, + suggested_action: suggestedAction, + } + } + + /** + * Format error message with structured JSON for LLM parsing (Phase 2 requirement) + */ + private formatErrorForLLM(error: HookError): string { + const jsonError = JSON.stringify(error, null, 2) + return `${error.message}\n\n\n${jsonError}\n` + } + + /** + * Check if workspace has existing code files to analyze + * Used to determine if agent should reverse engineer from code or create from prompt + */ + private async hasExistingCode(workspaceRoot: string): Promise { + const commonCodeDirs = ["src", "lib", "app", "components", "pages", "scripts", "server", "client"] + const commonCodeExtensions = [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c"] + + try { + // Check common code directories + for (const dir of commonCodeDirs) { + const dirPath = path.join(workspaceRoot, dir) + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + if (entries.length > 0) { + return true + } + } catch { + // Directory doesn't exist, continue + } + } + + // Check root directory for code files + const rootEntries = await fs.readdir(workspaceRoot, { withFileTypes: true }) + for (const entry of rootEntries) { + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase() + if (commonCodeExtensions.includes(ext)) { + return true + } + } + } + + return false + } catch { + return false + } + } + + /** + * Phase 4: Track file hash when file is read (for optimistic locking) + */ + trackFileRead(filePath: string, content: string): void { + const hash = this.dataModel.computeContentHash(content) + this.fileHashCache.set(filePath, { + hash, + timestamp: Date.now(), + }) + console.log(`[HOOK DEBUG] Tracked file read: ${filePath}, hash: ${hash.substring(0, 8)}...`) + } + + /** + * Phase 4: Validate file hasn't changed since last read (optimistic locking) + */ + private async validateFileNotStale( + filePath: string, + workspaceRoot: string, + ): Promise<{ valid: boolean; message?: string }> { + const cached = this.fileHashCache.get(filePath) + if (!cached) { + // No cached hash - allow write (first write or cache expired) + return { valid: true } + } + + // Check cache TTL + if (Date.now() - cached.timestamp > this.HASH_CACHE_TTL_MS) { + // Cache expired - allow write + this.fileHashCache.delete(filePath) + return { valid: true } + } + + // Read current file hash + try { + const absolutePath = path.resolve(workspaceRoot, filePath) + const currentContent = await fs.readFile(absolutePath, "utf-8") + const currentHash = this.dataModel.computeContentHash(currentContent) + + if (currentHash !== cached.hash) { + // File has changed - stale! + const error = this.createStructuredError( + "stale_file", + `File ${filePath} has been modified since it was last read. Please re-read the file before making changes.`, + { + file_path: filePath, + expected_hash: cached.hash.substring(0, 16), + actual_hash: currentHash.substring(0, 16), + reason: "file_modified_after_read", + }, + true, + "Re-read the file using read_file tool, then retry the write operation", + ) + + return { + valid: false, + message: this.formatErrorForLLM(error), + } + } + + // File is still the same - valid + return { valid: true } + } catch (error) { + // File might not exist (new file) - allow write + if ((error as any).code === "ENOENT") { + return { valid: true } + } + // Other error - fail open (allow write) + console.warn(`[HOOK DEBUG] Error validating file hash for ${filePath}:`, error) + return { valid: true } + } } /** * Pre-Hook: Intercept tool execution before it happens * Enforces intent context injection and scope validation + * + * Per document.md Phase 2: The Gatekeeper - verifies agent has declared a valid intent_id. + * If not, blocks execution and returns error message. + * + * Enhanced: Checks for architecture.md existence to guide agent in establishing intents + * before code changes (Plan-First strategy per document.md). + * + * Phase 4: Also validates optimistic locking (file hasn't changed since read) */ async preHook(toolName: ToolName, toolUse: ToolUse, task: Task): Promise { - // Check if this is select_active_intent - allow it through + // DEBUG: Log all hook calls + console.log( + `[HOOK DEBUG] preHook called - Tool: ${toolName}, ActiveIntentId: ${(task as any).activeIntentId || "undefined"}`, + ) + + // Check if this is select_active_intent - verify it's not in .intentignore if (toolName === "select_active_intent") { + const params = toolUse.params as any + const intentId = params?.intent_id as string | undefined + + if (intentId) { + // Phase 2: Check if intent is in .intentignore + const isIgnored = await this.dataModel.isIntentIgnored(intentId) + if (isIgnored) { + const error = this.createStructuredError( + "intent_protected", + `Intent "${intentId}" is protected and cannot be modified. This intent is listed in .orchestration/.intentignore.`, + { + intent_id: intentId, + reason: "intent_in_ignore_list", + }, + false, // Not recoverable - user must manually remove from ignore list + "Select a different intent or ask user to remove this intent from .intentignore", + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, + } + } + } + + return { shouldProceed: true } + } + + // Special case: create_intent is always allowed (it's a meta-tool for creating intents) + if (toolName === "create_intent") { + console.log(`[HOOK DEBUG] create_intent is always allowed (meta-tool for intent management)`) return { shouldProceed: true } } @@ -89,7 +325,7 @@ export class HookEngine { const activeIntentId = (task as any).activeIntentId as string | undefined let activeIntent = (task as any).activeIntent as ActiveIntent | undefined - // Destructive tools require intent selection + // Destructive tools require intent selection (document.md Phase 2 requirement) const destructiveTools: ToolName[] = [ "write_to_file", "edit_file", @@ -102,11 +338,100 @@ export class HookEngine { ] if (destructiveTools.includes(toolName)) { + console.log( + `[HOOK DEBUG] Destructive tool detected: ${toolName}, activeIntentId: ${activeIntentId || "undefined"}`, + ) if (!activeIntentId) { + // Check if docs/Architecture.md exists (Plan-First strategy per document.md) + const archPath = path.join(task.cwd, "docs", "Architecture.md") + let archExists = false + try { + await fs.access(archPath) + archExists = true + console.log(`[HOOK DEBUG] docs/Architecture.md exists at: ${archPath}`) + } catch { + archExists = false + console.log(`[HOOK DEBUG] docs/Architecture.md NOT found at: ${archPath}`) + } + + if (!archExists) { + console.log(`[HOOK DEBUG] docs/Architecture.md not found - showing error`) + const error = this.createStructuredError( + "architecture_missing", + `docs/Architecture.md is required but not found. Please create docs/Architecture.md with your project architecture and come again.`, + { + required_file: "docs/Architecture.md", + reason: "file_not_found", + required_action: "create_architecture_file", + instructions: [ + "Create docs/Architecture.md file in your project root", + "Document your project structure, directory layout, and intent areas", + "Then try your operation again", + ], + }, + true, + "Create docs/Architecture.md file with project architecture, then try again", + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, + } + } + + // docs/Architecture.md exists but no intent selected + const intentsData = await this.dataModel.readActiveIntents() + if (intentsData.active_intents.length === 0) { + console.log( + `[HOOK DEBUG] active_intents.yaml is empty - guide agent to create intents from docs/Architecture.md`, + ) + const error = this.createStructuredError( + "intents_missing", + `No active intent selected. docs/Architecture.md exists but active_intents.yaml is empty.`, + { + architecture_exists: true, + architecture_file: "docs/Architecture.md", + intents_file_empty: true, + required_action: "create_intents_from_architecture", + instructions: [ + "Read docs/Architecture.md to understand the project structure", + "Use create_intent(prompt) to create intents based on docs/Architecture.md content", + "Create intents for each major area (API, services, types, etc.)", + "Then call select_active_intent(intent_id) before making code changes", + ], + }, + true, + "Read docs/Architecture.md, use create_intent() to create intents based on it, then call select_active_intent(intent_id)", + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, + } + } + + // docs/Architecture.md exists and intents exist, but none selected + console.log( + `[HOOK DEBUG] docs/Architecture.md exists, intents exist, but no intent selected - blocking`, + ) + const error = this.createStructuredError( + "intent_not_selected", + "You must cite a valid active Intent ID. Call select_active_intent(intent_id) before making code changes.", + { + available_intents_file: ".orchestration/active_intents.yaml", + available_intents_count: intentsData.active_intents.length, + available_intent_ids: intentsData.active_intents.map((i) => i.id), + }, + true, + `Call select_active_intent(intent_id) with one of the available intent IDs from active_intents.yaml`, + ) + return { shouldProceed: false, - errorMessage: - "You must cite a valid active Intent ID. Call select_active_intent(intent_id) before making code changes.", + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } @@ -120,30 +445,179 @@ export class HookEngine { } if (!activeIntent) { + const intentsData = await this.dataModel.readActiveIntents() + const error = this.createStructuredError( + "intent_not_found", + `Intent "${activeIntentId}" not found in active_intents.yaml. Please select a valid intent ID.`, + { + requested_intent_id: activeIntentId, + available_intent_ids: intentsData.active_intents.map((i) => i.id), + available_intents_count: intentsData.active_intents.length, + }, + true, + `Use select_active_intent(intent_id) with one of the available intent IDs: ${intentsData.active_intents.map((i) => i.id).join(", ")}`, + ) + return { shouldProceed: false, - errorMessage: `Intent "${activeIntentId}" not found in active_intents.yaml. Please select a valid intent ID.`, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } - // Validate scope for write operations - if (toolName === "write_to_file" || toolName === "edit_file") { - const filePath = (toolUse.params as any).path as string | undefined + // Validate scope for file-mutating tools + const fileMutatingTools: ToolName[] = [ + "write_to_file", + "edit_file", + "apply_diff", + "apply_patch", + "edit", + "search_replace", + "search_and_replace", + ] + + if (fileMutatingTools.includes(toolName)) { + const filePath = this.extractTargetFilePath(toolName, toolUse) + if (filePath) { - const scopeValid = await this.validateScope(activeIntentId, filePath, task.cwd) - if (!scopeValid.valid) { + // Phase 4: Optimistic locking - check if file has changed since read + const staleCheck = await this.validateFileNotStale(filePath, task.cwd) + if (!staleCheck.valid) { return { shouldProceed: false, - errorMessage: `Scope Violation: ${activeIntentId} is not authorized to edit ${filePath}. ${scopeValid.message}`, + errorMessage: staleCheck.message, + structuredError: { + error_type: "stale_file", + message: staleCheck.message || "File has been modified", + recoverable: true, + suggested_action: "Re-read the file and retry", + }, + } + } + const scopeValid = await this.validateScope(activeIntentId, filePath, task.cwd) + if (!scopeValid.valid) { + // Check if there's any intent that matches this file path + const allIntents = await this.dataModel.readActiveIntents() + let matchingIntent = null + + for (const intent of allIntents.active_intents) { + const checkScope = await this.validateScope(intent.id, filePath, task.cwd) + if (checkScope.valid) { + matchingIntent = intent + break + } + } + + // If no matching intent found, guide agent to create one + if (!matchingIntent) { + const archPath = path.join(task.cwd, "docs", "Architecture.md") + let archExists = false + try { + await fs.access(archPath) + archExists = true + } catch { + archExists = false + } + + if (!archExists) { + const error = this.createStructuredError( + "architecture_missing", + `docs/Architecture.md is required but not found. Please create docs/Architecture.md with your project architecture and come again.`, + { + required_file: "docs/Architecture.md", + file_path: filePath, + reason: "file_not_found", + required_action: "create_architecture_file", + instructions: [ + "Create docs/Architecture.md file in your project root", + "Document your project structure, directory layout, and intent areas", + "Then try your operation again", + ], + }, + true, + "Create docs/Architecture.md file with project architecture, then try again", + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, + } + } + + // Guide agent to create intent based on file path and docs/Architecture.md + const inferredScope = this.inferScopeFromFilePath(filePath) + const error = this.createStructuredError( + "no_matching_intent", + `No active intent whose owned scope includes ${filePath}. Create a new intent based on docs/Architecture.md.`, + { + file_path: filePath, + architecture_file: "docs/Architecture.md", + inferred_scope: inferredScope, + action: "create_intent", + instructions: [ + `Read docs/Architecture.md to understand the project structure`, + `Call create_intent(prompt: "Intent for ${inferredScope}") to create a new intent`, + `The tool will infer scope, constraints, and acceptance criteria from docs/Architecture.md`, + `Then call select_active_intent(intent_id) with the newly created intent ID`, + ], + }, + true, + `Use create_intent() to create a new intent for ${inferredScope} based on docs/Architecture.md, then select it`, + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, + } + } + + // There is a matching intent, but it's not the selected one + const error = this.createStructuredError( + "scope_violation", + `Scope Violation: ${activeIntentId} is not authorized to edit ${filePath}. Use intent ${matchingIntent.id} instead.`, + { + intent_id: activeIntentId, + intent_name: activeIntent.name, + file_path: filePath, + intent_scope: activeIntent.owned_scope, + matching_intent_id: matchingIntent.id, + matching_intent_name: matchingIntent.name, + violation_reason: scopeValid.message, + }, + true, + `Call select_active_intent(${matchingIntent.id}) to use the intent that covers ${filePath}`, + ) + + return { + shouldProceed: false, + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } // UI-blocking authorization (HITL) const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName, { filePath }) if (!auth.approved) { + const error = this.createStructuredError( + "user_rejected", + auth.message || "Operation rejected by user.", + { + intent_id: activeIntent.id, + intent_name: activeIntent.name, + tool_name: toolName, + file_path: filePath, + rejection_reason: "user_denied_authorization", + }, + false, + "User must approve the operation or select a different approach", + ) + return { shouldProceed: false, - errorMessage: auth.message || "Operation rejected by user.", + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } } @@ -151,18 +625,47 @@ export class HookEngine { const command = (toolUse.params as any).command as string | undefined const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName, { command }) if (!auth.approved) { + const error = this.createStructuredError( + "user_rejected", + auth.message || "Operation rejected by user.", + { + intent_id: activeIntent.id, + intent_name: activeIntent.name, + tool_name: toolName, + command: command, + rejection_reason: "user_denied_authorization", + }, + false, + "User must approve the operation or select a different approach", + ) + return { shouldProceed: false, - errorMessage: auth.message || "Operation rejected by user.", + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } } else { // Other destructive tools: still require UI-blocking authorization. const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName) if (!auth.approved) { + const error = this.createStructuredError( + "user_rejected", + auth.message || "Operation rejected by user.", + { + intent_id: activeIntent.id, + intent_name: activeIntent.name, + tool_name: toolName, + rejection_reason: "user_denied_authorization", + }, + false, + "User must approve the operation or select a different approach", + ) + return { shouldProceed: false, - errorMessage: auth.message || "Operation rejected by user.", + errorMessage: this.formatErrorForLLM(error), + structuredError: error, } } } @@ -187,10 +690,35 @@ export class HookEngine { "edit", "search_replace", "search_and_replace", + "execute_command", ] if (destructiveTools.includes(toolName) && activeIntentId && success) { + console.log(`[HOOK DEBUG] postHook - logging trace for tool: ${toolName}, intent: ${activeIntentId}`) await this.logTraceEntry(toolName, toolUse, task, activeIntentId, result) + + // Phase 4: Update file hash cache after successful write + const nativeArgs = (toolUse as any).nativeArgs + const params = toolUse.params as any + const filePath = (nativeArgs?.path || nativeArgs?.file_path || params?.path || params?.file_path) as + | string + | undefined + + if (filePath) { + try { + const absolutePath = path.resolve(task.cwd, filePath) + const newContent = await fs.readFile(absolutePath, "utf-8") + this.trackFileRead(filePath, newContent) + console.log(`[HOOK DEBUG] Updated file hash cache after write: ${filePath}`) + } catch (error) { + // File might not exist or be unreadable - ignore + console.warn(`[HOOK DEBUG] Could not update file hash cache for ${filePath}:`, error) + } + } + } else { + console.log( + `[HOOK DEBUG] postHook - skipping trace: tool=${toolName}, intent=${activeIntentId || "none"}, success=${success}`, + ) } } @@ -209,11 +737,9 @@ export class HookEngine { } const normalizedPath = path.normalize(filePath) - const absolutePath = path.resolve(workspaceRoot, normalizedPath) // Check if file matches any scope pattern for (const scopePattern of intent.owned_scope) { - // Simple glob matching (can be enhanced with minimatch later) if (this.matchesPattern(normalizedPath, scopePattern)) { return { valid: true } } @@ -233,14 +759,48 @@ export class HookEngine { * Simple pattern matching (supports ** and *) */ private matchesPattern(filePath: string, pattern: string): boolean { + // Normalize both to forward slashes for consistent matching + const normalizedFile = filePath.replace(/\\/g, "/") + const normalizedPattern = pattern.replace(/\\/g, "/") + // Convert glob pattern to regex - const regexPattern = pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\//g, "\\/") + const regexPattern = normalizedPattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\//g, "\\/") const regex = new RegExp(`^${regexPattern}$`) - return regex.test(filePath) + return regex.test(normalizedFile) + } + + /** + * Infer scope pattern from file path + */ + private inferScopeFromFilePath(filePath: string): string { + const normalizedPath = path.normalize(filePath).replace(/\\/g, "/") + const parts = normalizedPath.split("/") + + // If it's in src/, infer the scope based on the directory structure + if (parts[0] === "src" && parts.length > 1) { + const dir = parts[1] + return `src/${dir}/**` + } + + // Default: use the parent directory pattern + if (parts.length > 1) { + const parentDir = parts.slice(0, -1).join("/") + return `${parentDir}/**` + } + + // Fallback: just the file's directory + return path.dirname(normalizedPath).replace(/\\/g, "/") + "/**" } /** * Log trace entry to agent_trace.jsonl + * + * FIX: Now correctly extracts file paths from both nativeArgs and params, + * handling different param names across tools: + * - write_to_file uses "path" + * - edit_file, edit, search_replace use "file_path" + * - apply_diff uses "path" + * - apply_patch extracts paths from the patch content */ private async logTraceEntry( toolName: ToolName, @@ -258,33 +818,90 @@ export class HookEngine { // Git not available or not a git repo } - // Extract file path and content from tool params + // FIX: Extract file path from nativeArgs first (typed), then fall back to params + // Different tools use different parameter names for the file path + const nativeArgs = (toolUse as any).nativeArgs const params = toolUse.params as any - const filePath = params.path as string | undefined - if (!filePath) { - return // Can't log without file path + let filePath = // nativeArgs (preferred - typed) + (nativeArgs?.path || + nativeArgs?.file_path || + // params (fallback - stringified) + params?.path || + params?.file_path) as string | undefined + + // If no direct file path (e.g., apply_patch), extract from patch content + let resolvedFilePath = filePath + if (!resolvedFilePath) { + const patchContent = nativeArgs?.patch || params?.patch + if (patchContent && typeof patchContent === "string") { + // Try unified diff format first: "--- a/path/to/file.ts" or "+++ b/path/to/file.ts" + const unifiedDiffMatch = patchContent.match(/^(?:---|\+\+\+)\s+(?:a\/|b\/)?(.+?)(?:\s|$)/m) + if (unifiedDiffMatch && unifiedDiffMatch[1]) { + resolvedFilePath = unifiedDiffMatch[1].trim() + } else { + // Try custom format: "Update File: path/to/file.ts" or "Create File: path/to/file.ts" + const fileMatches = patchContent.match(/(?:Update|Create|Delete)\s+File:\s*(.+)/gm) + if (fileMatches && fileMatches.length > 0) { + // Use the first file path found + const match = fileMatches[0].match(/(?:Update|Create|Delete)\s+File:\s*(.+)/) + if (match && match[1]) { + resolvedFilePath = match[1].trim() + } + } + } + } } + if (!resolvedFilePath) { + console.warn( + `[HOOK DEBUG] logTraceEntry - no file path found for tool ${toolName}. ` + + `nativeArgs keys: ${nativeArgs ? Object.keys(nativeArgs).join(", ") : "none"}, ` + + `params keys: ${params ? Object.keys(params).join(", ") : "none"}`, + ) + return + } + + console.log( + `[HOOK DEBUG] logTraceEntry - tool: ${toolName}, file: ${resolvedFilePath}, intent: ${intentId}`, + ) + // Read file content to compute hash - const absolutePath = path.resolve(task.cwd, filePath) + const absolutePath = path.resolve(task.cwd, resolvedFilePath) let fileContent = "" + let oldContent: string | null = null let startLine = 1 let endLine = 1 + // Phase 3: Get old content for mutation classification + // Try to get from cached hash or read from trace try { + const cached = this.fileHashCache.get(resolvedFilePath) + if (cached) { + // We have a cached hash, but we need the actual content for classification + // For now, we'll read the file and compare + // In a more sophisticated implementation, we could store content snippets + } + fileContent = await fs.readFile(absolutePath, "utf-8") const lines = fileContent.split("\n") endLine = lines.length // If we have line numbers in params, use them if (params.start_line !== undefined && params.end_line !== undefined) { - startLine = params.start_line - endLine = params.end_line + startLine = parseInt(params.start_line, 10) || 1 + endLine = parseInt(params.end_line, 10) || lines.length } - } catch { - // File doesn't exist or can't be read - return + + // Phase 3: Try to get old content from task's diffViewProvider if available + // This is a best-effort approach - in production, you might want a more robust solution + if ((task as any).diffViewProvider?.originalContent !== undefined) { + oldContent = (task as any).diffViewProvider.originalContent + } + } catch (readError) { + console.warn(`[HOOK DEBUG] logTraceEntry - could not read file ${absolutePath}: ${readError}`) + // File might have been deleted or moved - still log the trace entry + // Use empty content for hash } // Extract relevant code block @@ -293,19 +910,27 @@ export class HookEngine { const codeBlock = relevantLines.join("\n") const contentHash = this.dataModel.computeContentHash(codeBlock) + // Phase 3: Classify mutation type + const classification = this.dataModel.classifyMutation(oldContent, fileContent, resolvedFilePath) + console.log( + `[HOOK DEBUG] Mutation classification: ${classification.mutation_class} (${classification.confidence}) - ${classification.reason}`, + ) + // Get model identifier from task const modelId = task.api.getModel().id - // Build trace entry + // Build trace entry with Phase 3 enhancements const traceEntry = { id: `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, timestamp: new Date().toISOString(), + tool_name: toolName, + mutation_class: classification.mutation_class, // Phase 3: Include mutation classification vcs: { revision_id: gitRevision, }, files: [ { - relative_path: filePath, + relative_path: resolvedFilePath, conversations: [ { url: `task-${task.taskId}`, @@ -333,10 +958,133 @@ export class HookEngine { } await this.dataModel.appendTraceEntry(traceEntry) + console.log( + `[HOOK DEBUG] logTraceEntry - trace entry appended for ${resolvedFilePath} under intent ${intentId}`, + ) + + // Update the spatial map as living documentation (sidecar-only; should not affect usability). + await this.updateIntentMapFromMutation({ + task, + intentId, + filePath: resolvedFilePath, + startLine, + endLine, + mutationClass: classification.mutation_class, + timestamp: traceEntry.timestamp, + }) } catch (error) { console.error("Failed to log trace entry:", error) // Don't throw - logging failures shouldn't break tool execution } } -} + /** + * Keep `.orchestration/intent_map.md` updated so it acts as living documentation. + * Best-effort: failures should never block tool execution. + */ + private async updateIntentMapFromMutation(args: { + task: Task + intentId: string + filePath: string + startLine: number + endLine: number + mutationClass: "AST_REFACTOR" | "INTENT_EVOLUTION" + timestamp: string + }): Promise { + try { + const mapPath = path.join(this.dataModel.getOrchestrationDir(), "intent_map.md") + + let content = "" + try { + content = await fs.readFile(mapPath, "utf-8") + } catch { + content = `# Intent Map + +This file maps high-level business intents to physical files and AST nodes. + +## Intents + +` + } + + // Best-effort intent metadata + let intentName: string | undefined + let intentStatus: string | undefined + const cachedIntent = (args.task as any).activeIntent as ActiveIntent | undefined + if (cachedIntent?.id === args.intentId) { + intentName = cachedIntent.name + intentStatus = cachedIntent.status + } else { + const loaded = await this.dataModel.getIntent(args.intentId) + if (loaded) { + intentName = loaded.name + intentStatus = loaded.status + } + } + + const sectionTitle = intentName ? `${args.intentId}: ${intentName}` : `${args.intentId}` + const changeLine = ` - ${args.timestamp}: \`${args.filePath}\` (lines ${args.startLine}-${args.endLine}) — ${args.mutationClass}` + + const headerRegex = new RegExp(`^##\\s+${this.escapeRegExp(args.intentId)}\\b.*$`, "m") + const match = content.match(headerRegex) + + if (!match || match.index === undefined) { + const statusLine = intentStatus ? `- **Status:** ${intentStatus}\n` : "" + const section = + `\n## ${sectionTitle}\n` + + `- **Last Updated:** ${args.timestamp}\n` + + statusLine + + `- **Recent Changes:**\n` + + `${changeLine}\n` + await fs.writeFile(mapPath, content.trimEnd() + section + "\n", "utf-8") + return + } + + const sectionStart = match.index + const afterHeaderIdx = content.indexOf("\n", sectionStart) + const nextHeaderRel = content.slice(afterHeaderIdx + 1).search(/^##\s+/m) + const sectionEnd = nextHeaderRel === -1 ? content.length : afterHeaderIdx + 1 + nextHeaderRel + + // Ensure Last Updated exists (insert right after header line) + const headerLineEnd = afterHeaderIdx + 1 + const sectionSlice = content.slice(sectionStart, sectionEnd) + if (sectionSlice.includes("- **Last Updated:**")) { + content = + content.slice(0, sectionStart) + + sectionSlice.replace(/^\- \*\*Last Updated:\*\* .*$/m, `- **Last Updated:** ${args.timestamp}`) + + content.slice(sectionEnd) + } else { + content = + content.slice(0, headerLineEnd) + + `- **Last Updated:** ${args.timestamp}\n` + + content.slice(headerLineEnd) + } + + // Recompute section bounds after possible insertion + const updatedAfterHeaderIdx = content.indexOf("\n", sectionStart) + const updatedNextHeaderRel = content.slice(updatedAfterHeaderIdx + 1).search(/^##\s+/m) + const updatedSectionEnd = + updatedNextHeaderRel === -1 ? content.length : updatedAfterHeaderIdx + 1 + updatedNextHeaderRel + + const updatedSection = content.slice(sectionStart, updatedSectionEnd) + if (updatedSection.includes("- **Recent Changes:**")) { + const rcIdx = content.indexOf("- **Recent Changes:**", sectionStart) + const insertAt = content.indexOf("\n", rcIdx) + 1 + content = content.slice(0, insertAt) + changeLine + "\n" + content.slice(insertAt) + } else { + // Append block within the section + const statusLine = intentStatus ? `- **Status:** ${intentStatus}\n` : "" + const addBlock = `\n${statusLine}- **Recent Changes:**\n${changeLine}\n` + content = content.slice(0, updatedSectionEnd) + addBlock + content.slice(updatedSectionEnd) + } + + await fs.writeFile(mapPath, content, "utf-8") + } catch (error) { + console.warn("[HOOK DEBUG] Failed to update intent_map.md:", error) + } + } + + private escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } +}