mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-13 23:11:10 +00:00
chore: commit src/hooks/HookEngine.ts
This commit is contained in:
parent
7eaba1b298
commit
5ce9e584ae
1 changed files with 779 additions and 31 deletions
|
|
@ -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<string, { hash: string; timestamp: number }> = 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<void> {
|
||||
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<string, any>,
|
||||
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<error_details>\n${jsonError}\n</error_details>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<HookResult> {
|
||||
// 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<void> {
|
||||
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, "\\$&")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue