From 40fb5593f54cef576671c86487958a700754203f Mon Sep 17 00:00:00 2001 From: Melaku Date: Wed, 18 Feb 2026 09:11:21 +0300 Subject: [PATCH] hook system --- .orchestration/CLAUDE.md | 44 +++ .orchestration/README.md | 27 ++ .orchestration/active_intents.yaml | 41 ++ .orchestration/intent_map.md | 33 ++ packages/types/src/tool.ts | 1 + src/ARCHITECTURE_NOTES.md | 170 ++++++++- .../presentAssistantMessage.ts | 8 + src/core/prompts/tools/native-tools/index.ts | 2 + .../native-tools/select_active_intent.ts | 49 +++ src/core/task/Task.ts | 8 + src/core/tools/SelectActiveIntentTool.ts | 71 ++++ src/hooks/HookEngine.ts | 361 ++++++++++++++++++ src/hooks/IntentValidator.ts | 103 +++++ src/hooks/TraceLogger.ts | 204 ++++++++++ src/hooks/index.ts | 73 ++++ src/hooks/types.ts | 247 ++++++++++++ src/shared/tools.ts | 3 + 17 files changed, 1437 insertions(+), 8 deletions(-) create mode 100644 .orchestration/CLAUDE.md create mode 100644 .orchestration/README.md create mode 100644 .orchestration/active_intents.yaml create mode 100644 .orchestration/intent_map.md create mode 100644 src/core/prompts/tools/native-tools/select_active_intent.ts create mode 100644 src/core/tools/SelectActiveIntentTool.ts create mode 100644 src/hooks/HookEngine.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/types.ts diff --git a/.orchestration/CLAUDE.md b/.orchestration/CLAUDE.md new file mode 100644 index 0000000000..96c97ed3f5 --- /dev/null +++ b/.orchestration/CLAUDE.md @@ -0,0 +1,44 @@ +# Shared Brain - Agent Collaboration + +## Purpose + +A persistent knowledge base shared across parallel sessions (Architect/Builder/Tester). +Contains "Lessons Learned" and project-specific stylistic rules. + +## Project Rules + +### Code Style + +- Use TypeScript for all new code +- Follow existing code patterns in the codebase +- Add unit tests for new functionality + +### Intent-Driven Development + +1. Always select an intent before making changes: `select_active_intent` +2. Check the scope before editing files +3. Log all changes to the trace + +### Parallel Workflow + +- Architect agent defines the plan in intent_map.md +- Builder agent implements code +- Tester agent verifies against acceptance criteria + +## Lessons Learned + +### 2026-02-17 + +- Initial setup of Intent-Code Traceability system +- System enforces that agents must "checkout" an intent before writing code +- Scope validation prevents unauthorized file modifications + +## Active Sessions + +- Agent A (Architect): Monitoring intent_map.md +- Agent B (Builder): Working on INT-001 + +## Notes + +- If you encounter a "Stale File" error, re-read the file before overwriting +- Use `select_active_intent` to load context before starting work diff --git a/.orchestration/README.md b/.orchestration/README.md new file mode 100644 index 0000000000..483c801350 --- /dev/null +++ b/.orchestration/README.md @@ -0,0 +1,27 @@ +# Intent-Code Traceability Orchestration Directory + +# + +# This directory contains the state files for the Intent-Code Traceability system. + +# It is managed by the Roo Code hook system and should not be manually edited + +# unless you are initializing a new workspace. + +# Files in this directory: + +# - active_intents.yaml: Business intent specifications + +# - agent_trace.jsonl: Append-only ledger of all code changes + +# - intent_map.md: Spatial map of intents to files + +# - CLAUDE.md: Shared brain for parallel agent sessions + +# To initialize a new workspace: + +# 1. Create this .orchestration directory + +# 2. Create active_intents.yaml with your intent specifications + +# 3. The system will automatically create other files as needed diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 0000000000..087589d45b --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,41 @@ +# Active Intents Specification +# +# This file tracks the lifecycle of business requirements. +# Not all code changes are equal; this file tracks WHY we are working. + +active_intents: + - id: "INT-001" + name: "Weather API Implementation" + status: "PENDING" + # Formal Scope Definition (Crucial for Parallelism) + owned_scope: + - "src/weather/**" + - "src/api/weather.ts" + constraints: + - "Must use OpenWeatherMap API" + - "Must support both current weather and forecast" + - "Must include error handling for API failures" + # The "Definition of Done" + acceptance_criteria: + - "Unit tests in tests/weather/ pass" + - "API endpoint returns valid JSON" + - "Rate limiting implemented" + created_at: "2026-02-17T00:00:00Z" + updated_at: "2026-02-17T00:00:00Z" + + - id: "INT-002" + name: "User Authentication Refactor" + status: "PENDING" + owned_scope: + - "src/auth/**" + - "src/middleware/auth.ts" + constraints: + - "Must maintain backward compatibility with existing API" + - "Must support JWT and OAuth2" + - "Passwords must be hashed with bcrypt" + acceptance_criteria: + - "All auth tests pass" + - "No breaking changes to existing API contracts" + - "Security audit passes" + created_at: "2026-02-17T00:00:00Z" + updated_at: "2026-02-17T00:00:00Z" diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 0000000000..a9d7d50614 --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,33 @@ +# Intent Map + +## Purpose + +This file maps high-level business intents to physical files and AST nodes. +When a manager asks, "Where is the billing logic?", this file provides the answer. + +## Intent to File Mapping + +### INT-001: Weather API Implementation + +- **Scope**: `src/weather/**`, `src/api/weather.ts` +- **Primary Files**: + - `src/api/weather.ts` - Main API endpoint + - `src/weather/client.ts` - OpenWeatherMap client + - `src/weather/types.ts` - Type definitions + - `src/weather/forecast.ts` - Forecast logic + +### INT-002: User Authentication Refactor + +- **Scope**: `src/auth/**`, `src/middleware/auth.ts` +- **Primary Files**: + - `src/auth/middleware.ts` - Auth middleware + - `src/auth/jwt.ts` - JWT handling + - `src/auth/oauth.ts` - OAuth2 implementation + - `src/auth/password.ts` - Password hashing + +## Intent Evolution History + +(Automatically updated when INTENT_EVOLUTION occurs) + +- 2026-02-17: INT-001 created +- 2026-02-17: INT-002 created diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..3655a5f29e 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,7 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + "select_active_intent", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/ARCHITECTURE_NOTES.md b/src/ARCHITECTURE_NOTES.md index c4ab69361b..5c3285d3b8 100644 --- a/src/ARCHITECTURE_NOTES.md +++ b/src/ARCHITECTURE_NOTES.md @@ -1,12 +1,166 @@ -# Architecture Notes – Day 0 +# Architecture Notes – TRP1 Challenge Implementation -## What I achieved +## Phase 0: Archaeological Dig - Roo Code Architecture -- Successfully ran Roo Code in Extension Development Host -- Located main activation file -- Reloaded extension after a code change +### Overview -## Open Questions +Roo Code is an AI-native IDE extension for VS Code built on top of the Anthropic Messages API. It orchestrates AI agents to execute development tasks through a sophisticated tool execution system. -- Where tool execution happens -- Where system prompt is built +### Key Components + +#### 1. Entry Point + +- **`src/extension.ts`**: Main extension activation file + - Initializes VS Code extension context + - Registers commands and providers + - Loads environment variables from `.env` + +#### 2. Task Orchestration (The Core Loop) + +- **`src/core/task/Task.ts`**: Main task orchestration class (~17,700+ lines) + - Manages the conversation loop with the LLM + - Handles message streaming and tool execution scheduling + - Maintains conversation history + - Controls task state (paused, aborted, etc.) + +#### 3. Tool Execution Pipeline + +- **`src/core/assistant-message/presentAssistantMessage.ts`**: Tool execution entry point + - Uses a switch statement on `block.name` to route to specific tools + - Key execution points: + - `write_to_file` → `WriteToFileTool.handle()` + - `execute_command` → `ExecuteCommandTool.handle()` + - `read_file` → `ReadFileTool.handle()` + - etc. + +#### 4. Tool Definitions + +- **`src/core/tools/BaseTool.ts`**: Abstract base class for all tools + + - Defines `execute()` method that tools implement + - Handles parameter parsing from `nativeArgs` + - Provides `handlePartial()` for streaming responses + +- **Individual tools** in `src/core/tools/`: + - `WriteToFileTool.ts` - File writing with diff view + - `ExecuteCommandTool.ts` - Shell command execution + - `ReadFileTool.ts` - File reading + - `EditTool.ts`, `SearchReplaceTool.ts` - File editing + - `ApplyPatchTool.ts`, `ApplyDiffTool.ts` - Patch application + +#### 5. System Prompt Generation + +- **`src/core/prompts/system.ts`**: Builds the SYSTEM_PROMPT + - Called with `vscode.ExtensionContext` and other parameters + - Includes mode-specific instructions + - Provides tool definitions and capabilities + +### Hook System Architecture + +#### Injection Points + +1. **Pre-Hook (Before tool execution)**: + + - In `presentAssistantMessage.ts`, before each `tool.handle()` call + - Can intercept, validate, and modify tool parameters + - Can block execution and return error results + +2. **Post-Hook (After tool execution)**: + - After successful tool execution + - For logging traces and updating documentation + +#### Intent-Driven Workflow + +1. **State 1 - Request**: User prompts the agent +2. **State 2 - Reasoning Intercept**: Agent must call `select_active_intent` tool + - Pre-Hook intercepts this call + - Loads intent context from `.orchestration/active_intents.yaml` + - Injects context into the prompt +3. **State 3 - Contextualized Action**: Agent executes tools with full context + - Pre-Hook validates scope before write operations + - Post-Hook logs traces to `.orchestration/agent_trace.jsonl` + +### Data Model Files (`.orchestration/`) + +#### 1. `active_intents.yaml` + +Tracks business requirements and their lifecycle: + +```yaml +active_intents: + - id: "INT-001" + name: "JWT Authentication Migration" + status: "IN_PROGRESS" + owned_scope: + - "src/auth/**" + - "src/middleware/jwt.ts" + constraints: + - "Must not use external auth providers" + - "Must maintain backward compatibility" + acceptance_criteria: + - "Unit tests pass" +``` + +#### 2. `agent_trace.jsonl` + +Append-only ledger linking Intent → Code Hash: + +```json +{ + "id": "uuid-v4", + "timestamp": "2026-02-16T12:00:00Z", + "vcs": { "revision_id": "git_sha_hash" }, + "files": [ + { + "relative_path": "src/auth/middleware.ts", + "conversations": [ + { + "url": "session_log_id", + "contributor": { + "entity_type": "AI", + "model_identifier": "claude-3-5-sonnet" + }, + "ranges": [ + { + "start_line": 15, + "end_line": 45, + "content_hash": "sha256:a8f5f167f44f4964e6c998dee827110c" + } + ], + "related": [{ "type": "specification", "value": "REQ-001" }] + } + ] + } + ] +} +``` + +#### 3. `intent_map.md` + +Spatial map of business intents to files/AST nodes. + +#### 4. `CLAUDE.md` (or `AGENT.md`) + +Shared brain for parallel agent sessions. + +### Hook Implementation Strategy + +1. **Hook Engine**: `src/hooks/HookEngine.ts` + + - Central middleware that wraps tool execution + - Provides Pre-Hook and Post-Hook capabilities + - Manages intent state across the session + +2. **Intent Validator**: `src/hooks/IntentValidator.ts` + + - Validates intent IDs and scope + - Blocks unauthorized operations + +3. **Trace Logger**: `src/hooks/TraceLogger.ts` + + - Computes content hashes + - Appends to `agent_trace.jsonl` + +4. **New Tool**: `select_active_intent` + - Forces the reasoning loop + - Returns intent context to the agent diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..03df4865fc 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,6 +37,7 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { selectActiveIntentTool } from "../tools/SelectActiveIntentTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" @@ -849,6 +850,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "select_active_intent": + await selectActiveIntentTool.handle(cline, block as ToolUse<"select_active_intent">, { + askApproval, + handleError, + pushToolResult, + }) + break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..adbb294ecb 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -20,6 +20,7 @@ import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" +import selectActiveIntent from "./select_active_intent" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -68,6 +69,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch switchMode, updateTodoList, writeToFile, + selectActiveIntent, ] satisfies OpenAI.Chat.ChatCompletionTool[] } diff --git a/src/core/prompts/tools/native-tools/select_active_intent.ts b/src/core/prompts/tools/native-tools/select_active_intent.ts new file mode 100644 index 0000000000..7ae20e7d69 --- /dev/null +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -0,0 +1,49 @@ +import type OpenAI from "openai" + +const SELECT_ACTIVE_INTENT_DESCRIPTION = `Select an active intent before performing any destructive operations like writing files, editing code, or executing commands. This is a MANDATORY step that must be completed before any code modifications. + +The Intent-Code Traceability system requires you to "checkout" an intent just like you would check out a branch in version control. This ensures: +- You have the correct context for the task +- Your changes are properly tracked and attributed +- Scope violations are prevented + +How to Use: +1. First, analyze the user's request to understand what they're asking for +2. Identify the appropriate intent ID from .orchestration/active_intents.yaml +3. Call this tool with the intent_id to load the context +4. Only AFTER selecting an intent, proceed with your task + +When to Use: +- Before ANY write_to_file, edit, search_and_replace, or similar file-modifying operations +- Before execute_command if it will modify the codebase +- When starting any new task or subtask + +When NOT to Use: +- For read-only operations (read_file, list_files, search_files are safe) +- After you have already selected an intent and are continuing within the same intent + +Example: { "intent_id": "INT-001" } + +Note: If no active_intents.yaml exists, you must first create the .orchestration directory and define your intents.` + +const INTENT_ID_PARAMETER_DESCRIPTION = `The intent ID from .orchestration/active_intents.yaml (e.g., "INT-001", "INT-002")` + +export default { + type: "function", + function: { + name: "select_active_intent", + description: SELECT_ACTIVE_INTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: INTENT_ID_PARAMETER_DESCRIPTION, + }, + }, + required: ["intent_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac..ddbacd3203 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -479,6 +479,14 @@ export class Task extends EventEmitter implements TaskLike { this.instanceId = crypto.randomUUID().slice(0, 8) this.taskNumber = -1 + // Initialize the Hook Engine for Intent-Code Traceability + try { + const { initializeHookEngine } = require("../../hooks/index") + initializeHookEngine(this.cwd, this.taskId, this.instanceId) + } catch (error) { + console.warn("[Task] Failed to initialize Hook Engine:", error) + } + this.rooIgnoreController = new RooIgnoreController(this.cwd) this.rooProtectedController = new RooProtectedController(this.cwd) this.fileContextTracker = new FileContextTracker(provider, this.taskId) diff --git a/src/core/tools/SelectActiveIntentTool.ts b/src/core/tools/SelectActiveIntentTool.ts new file mode 100644 index 0000000000..da30a4f00a --- /dev/null +++ b/src/core/tools/SelectActiveIntentTool.ts @@ -0,0 +1,71 @@ +/** + * Select Active Intent Tool + * + * This tool allows the agent to "checkout" an intent before performing any + * destructive operations. This enforces the Reasoning Loop pattern. + */ + +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { getHookEngine } from "../../hooks/HookEngine" +import { validateIntentId, formatIntentForDisplay } from "../../hooks/IntentValidator" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +interface SelectActiveIntentParams { + intent_id: string +} + +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { + readonly name = "select_active_intent" as const + + async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + const intentId = params.intent_id + + if (!intentId) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + pushToolResult(await task.sayAndCreateMissingParamError("select_active_intent", "intent_id")) + return + } + + // Validate intent ID + const validation = await validateIntentId(task.cwd, intentId) + + if (!validation.valid) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + pushToolResult(formatResponse.toolError(validation.error || "Invalid intent")) + return + } + + // Set the active intent in the hook engine + const hookEngine = getHookEngine() + const result = await hookEngine.setActiveIntent(intentId) + + if (!result.allowed) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + pushToolResult(formatResponse.toolError(result.errorMessage || "Failed to set active intent")) + return + } + + task.consecutiveMistakeCount = 0 + + // Format the intent context for display + const intentDisplay = formatIntentForDisplay(validation.intent!) + + const successMessage = ` +## Intent Selected Successfully + +${intentDisplay} + +You now have context to work within this intent's scope. You may proceed with your task. +When making file modifications, ensure they stay within the owned scope listed above. +`.trim() + + pushToolResult(successMessage) + } +} + +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts new file mode 100644 index 0000000000..831c933351 --- /dev/null +++ b/src/hooks/HookEngine.ts @@ -0,0 +1,361 @@ +/** + * Hook Engine - Middleware for Intent-Code Traceability + * + * This is the central middleware that intercepts all tool executions to: + * 1. Enforce intent context injection (Pre-Hook) + * 2. Validate scope and authorization + * 3. Log traces and update documentation (Post-Hook) + */ + +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs" +import { v4 as uuidv4 } from "uuid" +import { + type HookContext, + type PreHookResult, + type PostHookResult, + type ActiveIntent, + type AgentTraceEntry, + type MutationClass, + classifyTool, + computeContentHash, + getOrchestrationDir, + ensureOrchestrationDir, + loadActiveIntents, + saveActiveIntents, + getIntentById, + isFileInScope, + getGitRevision, +} from "./types" + +/** + * Session state for tracking active intent across the conversation + */ +interface SessionState { + activeIntentId: string | null + taskId: string + instanceId: string + startedAt: string +} + +/** + * The Hook Engine - main middleware for intercepting tool executions + */ +export class HookEngine { + private static instance: HookEngine | null = null + private sessionState: SessionState | null = null + private workspacePath: string = "" + + private constructor() {} + + /** + * Get singleton instance + */ + static getInstance(): HookEngine { + if (!HookEngine.instance) { + HookEngine.instance = new HookEngine() + } + return HookEngine.instance + } + + /** + * Initialize the hook engine with workspace context + */ + initialize(workspacePath: string, taskId: string, instanceId: string): void { + this.workspacePath = workspacePath + this.sessionState = { + activeIntentId: null, + taskId, + instanceId, + startedAt: new Date().toISOString(), + } + console.log(`[HookEngine] Initialized for workspace: ${workspacePath}`) + } + + /** + * Get the current active intent ID + */ + getActiveIntentId(): string | null { + return this.sessionState?.activeIntentId || null + } + + /** + * Set the active intent (called when agent selects an intent) + */ + async setActiveIntent(intentId: string): Promise { + if (!this.sessionState) { + return { + allowed: false, + errorMessage: "HookEngine not initialized", + } + } + + const intentsData = await loadActiveIntents(this.workspacePath) + if (!intentsData) { + return { + allowed: false, + errorMessage: "No active_intents.yaml found. Please initialize the orchestration directory.", + } + } + + const intent = getIntentById(intentsData, intentId) + if (!intent) { + return { + allowed: false, + errorMessage: `Intent ID '${intentId}' not found in active_intents.yaml`, + } + } + + // Update session state + this.sessionState.activeIntentId = intentId + + // Update intent status to IN_PROGRESS + intent.status = "IN_PROGRESS" + intent.updated_at = new Date().toISOString() + await saveActiveIntents(this.workspacePath, intentsData) + + // Generate context for injection + const injectedContext = this.generateIntentContext(intent) + + console.log(`[HookEngine] Active intent set to: ${intentId}`) + + return { + allowed: true, + injectedContext, + } + } + + /** + * Generate the intent context XML block for injection + */ + private generateIntentContext(intent: ActiveIntent): string { + const constraints = intent.constraints.map((c) => ` - ${c}`).join("\n") + const scope = intent.owned_scope.map((s) => ` - ${s}`).join("\n") + const acceptance = intent.acceptance_criteria.map((a) => ` - ${a}`).join("\n") + + return ` + + +${scope} + + +${constraints} + + +${acceptance} + + +`.trim() + } + + /** + * Pre-Hook: Called before tool execution + */ + async preHook(context: HookContext): Promise { + const { toolName, toolParams, cwd } = context + + // If no active intent is set, block destructive tools + if (!this.sessionState?.activeIntentId) { + const classification = classifyTool(toolName) + if (classification === "DESTRUCTIVE") { + return { + allowed: false, + errorMessage: `Scope Violation: No active intent selected. You must call 'select_active_intent' before performing destructive operations like '${toolName}'.`, + } + } + // Allow safe tools without intent + return { allowed: true } + } + + // Check scope for write operations + if ( + toolName === "write_to_file" || + toolName === "edit" || + toolName === "search_and_replace" || + toolName === "edit_file" + ) { + const filePath = (toolParams.path as string) || (toolParams.file_path as string) + if (filePath) { + const intentsData = await loadActiveIntents(cwd) + if (intentsData) { + const intent = getIntentById(intentsData, this.sessionState.activeIntentId) + if (intent) { + const isInScope = isFileInScope(filePath, intent.owned_scope) + if (!isInScope) { + return { + allowed: false, + errorMessage: `Scope Violation: Intent ${this.sessionState.activeIntentId} is not authorized to edit '${filePath}'. Authorized scope: ${intent.owned_scope.join(", ")}`, + } + } + } + } + } + } + + return { allowed: true } + } + + /** + * Post-Hook: Called after successful tool execution + */ + async postHook( + context: HookContext, + toolResult: string, + mutationClass: MutationClass = "UNKNOWN", + ): Promise { + const { toolName, toolParams, cwd } = context + const classification = classifyTool(toolName) + + // Only trace destructive/modifying operations + if (classification !== "DESTRUCTIVE") { + return { success: true } + } + + // Get the file path from tool params + const filePath = (toolParams.path as string) || (toolParams.file_path as string) || (toolParams.file as string) + + if (!filePath || !this.sessionState?.activeIntentId) { + return { success: true } + } + + try { + // Ensure orchestration directory exists + await ensureOrchestrationDir(cwd) + + // Read the current file content to compute hash + const fullPath = path.resolve(cwd, filePath) + let content = "" + let startLine = 1 + let endLine = 1 + + if (fs.existsSync(fullPath)) { + content = fs.readFileSync(fullPath, "utf-8") + const lines = content.split("\n") + endLine = lines.length + + // For new files, startLine would be 1 + // For edits, we'd need the actual range - for now use entire file + } + + // Compute content hash + const contentHash = computeContentHash(content) + + // Get git revision + const gitRevision = getGitRevision(cwd) + + // Create trace entry + const traceEntry: AgentTraceEntry = { + id: uuidv4(), + timestamp: new Date().toISOString(), + vcs: { + revision_id: gitRevision, + }, + files: [ + { + relative_path: filePath, + conversations: [ + { + url: this.sessionState.taskId, + contributor: { + entity_type: "AI", + model_identifier: "claude-3-5-sonnet", // Would get from actual model + }, + ranges: [ + { + start_line: startLine, + end_line: endLine, + content_hash: contentHash, + }, + ], + related: [ + { + type: "intent", + value: this.sessionState.activeIntentId, + }, + ], + }, + ], + }, + ], + } + + // Append to trace file + const tracePath = path.join(getOrchestrationDir(cwd), "agent_trace.jsonl") + const traceLine = JSON.stringify(traceEntry) + "\n" + fs.appendFileSync(tracePath, traceLine, "utf-8") + + console.log( + `[HookEngine] Traced ${toolName} on ${filePath} with intent ${this.sessionState.activeIntentId}`, + ) + + return { + success: true, + traceEntry, + } + } catch (error) { + console.error("[HookEngine] Post-hook error:", error) + return { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + } + } + } + + /** + * Check if a file has been modified since the agent started + * Used for optimistic locking in parallel orchestration + */ + async checkFileConcurrency( + filePath: string, + originalHash: string, + ): Promise<{ stale: boolean; currentHash: string }> { + const fullPath = path.resolve(this.workspacePath, filePath) + + if (!fs.existsSync(fullPath)) { + return { stale: false, currentHash: "" } + } + + const content = fs.readFileSync(fullPath, "utf-8") + const currentHash = computeContentHash(content) + + return { + stale: currentHash !== originalHash, + currentHash, + } + } + + /** + * Update intent status (for completion or blocking) + */ + async updateIntentStatus(intentId: string, status: "COMPLETED" | "BLOCKED"): Promise { + const intentsData = await loadActiveIntents(this.workspacePath) + if (!intentsData) return + + const intent = getIntentById(intentsData, intentId) + if (intent) { + intent.status = status + intent.updated_at = new Date().toISOString() + await saveActiveIntents(this.workspacePath, intentsData) + } + + if (this.sessionState?.activeIntentId === intentId) { + this.sessionState.activeIntentId = null + } + } + + /** + * Clear session state + */ + reset(): void { + this.sessionState = null + this.workspacePath = "" + } +} + +/** + * Convenience function to get the HookEngine instance + */ +export function getHookEngine(): HookEngine { + return HookEngine.getInstance() +} diff --git a/src/hooks/IntentValidator.ts b/src/hooks/IntentValidator.ts index e69de29bb2..fa0edb458d 100644 --- a/src/hooks/IntentValidator.ts +++ b/src/hooks/IntentValidator.ts @@ -0,0 +1,103 @@ +/** + * Intent Validator + * + * Validates intent IDs and scope before allowing tool execution. + * Part of the Pre-Hook security boundary. + */ + +import { loadActiveIntents, getIntentById, isFileInScope, type ActiveIntent, type ActiveIntentsData } from "./types" + +/** + * Validate an intent ID exists and is active + */ +export async function validateIntentId( + workspacePath: string, + intentId: string, +): Promise<{ valid: boolean; intent?: ActiveIntent; error?: string }> { + const intentsData = await loadActiveIntents(workspacePath) + + if (!intentsData) { + return { + valid: false, + error: "No active_intents.yaml found. Please initialize the orchestration directory.", + } + } + + const intent = getIntentById(intentsData, intentId) + + if (!intent) { + const availableIds = intentsData.active_intents.map((i) => i.id).join(", ") + return { + valid: false, + error: `Intent ID '${intentId}' not found. Available intents: ${availableIds || "none"}`, + } + } + + if (intent.status === "COMPLETED") { + return { + valid: false, + error: `Intent '${intentId}' has already been completed. Please select a different intent.`, + } + } + + if (intent.status === "BLOCKED") { + return { + valid: false, + error: `Intent '${intentId}' is blocked. Please resolve the blocking issue or select a different intent.`, + } + } + + return { valid: true, intent } +} + +/** + * Validate that a file is within the intent's owned scope + */ +export function validateFileScope(filePath: string, intent: ActiveIntent): { valid: boolean; error?: string } { + const isInScope = isFileInScope(filePath, intent.owned_scope) + + if (!isInScope) { + return { + valid: false, + error: `Scope Violation: Intent ${intent.id} is not authorized to edit '${filePath}'. Authorized scope: ${intent.owned_scope.join(", ")}. Request scope expansion in the intent specification.`, + } + } + + return { valid: true } +} + +/** + * Get all available intents for display + */ +export async function getAvailableIntents(workspacePath: string): Promise { + const intentsData = await loadActiveIntents(workspacePath) + if (!intentsData) return [] + + return intentsData.active_intents.filter((intent) => intent.status === "PENDING" || intent.status === "IN_PROGRESS") +} + +/** + * Format intent for display to the AI agent + */ +export function formatIntentForDisplay(intent: ActiveIntent): string { + const scope = + intent.owned_scope.length > 0 + ? `\n Owned Scope:\n${intent.owned_scope.map((s) => ` - ${s}`).join("\n")}` + : "" + + const constraints = + intent.constraints.length > 0 + ? `\n Constraints:\n${intent.constraints.map((c) => ` - ${c}`).join("\n")}` + : "" + + const acceptance = + intent.acceptance_criteria.length > 0 + ? `\n Acceptance Criteria:\n${intent.acceptance_criteria.map((a) => ` - ${a}`).join("\n")}` + : "" + + return ` +## Intent: ${intent.name} (${intent.id}) + Status: ${intent.status} +${scope}${constraints}${acceptance} +`.trim() +} diff --git a/src/hooks/TraceLogger.ts b/src/hooks/TraceLogger.ts index e69de29bb2..233c1a2e42 100644 --- a/src/hooks/TraceLogger.ts +++ b/src/hooks/TraceLogger.ts @@ -0,0 +1,204 @@ +/** + * Trace Logger + * + * Handles logging of agent actions to the append-only ledger (agent_trace.jsonl). + * Part of the Post-Hook system for AI-Native Git Layer. + */ + +import * as path from "path" +import * as fs from "fs" +import { v4 as uuidv4 } from "uuid" +import { + type AgentTraceEntry, + type TraceFileEntry, + type TraceConversation, + type MutationClass, + computeContentHash, + getOrchestrationDir, + getGitRevision, +} from "./types" + +export interface LogTraceParams { + workspacePath: string + taskId: string + instanceId: string + intentId: string + filePath: string + content: string + startLine: number + endLine: number + modelIdentifier?: string + mutationClass: MutationClass +} + +/** + * Log a trace entry to the append-only ledger + */ +export async function logTrace(params: LogTraceParams): Promise { + const { + workspacePath, + taskId, + intentId, + filePath, + content, + startLine, + endLine, + modelIdentifier = "claude-3-5-sonnet", + } = params + + // Ensure orchestration directory exists + const orchDir = getOrchestrationDir(workspacePath) + if (!fs.existsSync(orchDir)) { + fs.mkdirSync(orchDir, { recursive: true }) + } + + // Compute content hash + const contentHash = computeContentHash(content) + + // Get git revision + const gitRevision = getGitRevision(workspacePath) + + // Create trace entry + const traceEntry: AgentTraceEntry = { + id: uuidv4(), + timestamp: new Date().toISOString(), + vcs: { + revision_id: gitRevision, + }, + files: [ + { + relative_path: filePath, + conversations: [ + { + url: taskId, + contributor: { + entity_type: "AI", + model_identifier: modelIdentifier, + }, + ranges: [ + { + start_line: startLine, + end_line: endLine, + content_hash: contentHash, + }, + ], + related: [ + { + type: "intent", + value: intentId, + }, + ], + }, + ], + }, + ], + } + + // Append to trace file (JSONL format) + const tracePath = path.join(orchDir, "agent_trace.jsonl") + const traceLine = JSON.stringify(traceEntry) + "\n" + fs.appendFileSync(tracePath, traceLine, "utf-8") + + console.log(`[TraceLogger] Logged trace for ${filePath} with intent ${intentId}`) + + return traceEntry +} + +/** + * Classify the mutation based on context + * This is a simplified version - in production would use AST analysis + */ +export function classifyMutation(originalContent: string, newContent: string, intent?: string): MutationClass { + // If the content is very similar, it's likely a refactor + const similarity = calculateSimilarity(originalContent, newContent) + + if (similarity > 0.8) { + return "AST_REFACTOR" + } + + // If significantly different, it's likely a new feature/evolution + if (similarity < 0.5) { + return "INTENT_EVOLUTION" + } + + return "UNKNOWN" +} + +/** + * Calculate simple similarity between two strings + */ +function calculateSimilarity(str1: string, str2: string): number { + if (str1 === str2) return 1 + if (!str1 || !str2) return 0 + + const longer = str1.length > str2.length ? str1 : str2 + const shorter = str1.length > str2.length ? str2 : str1 + + if (longer.length === 0) return 1 + + const editDistance = levenshteinDistance(longer, shorter) + return (longer.length - editDistance) / longer.length +} + +/** + * Calculate Levenshtein distance between two strings + */ +function levenshteinDistance(str1: string, str2: string): number { + const matrix: number[][] = [] + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i] + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1] + } else { + matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1) + } + } + } + + return matrix[str2.length][str1.length] +} + +/** + * Read the trace history for a specific intent + */ +export async function getTraceHistoryForIntent(workspacePath: string, intentId: string): Promise { + const tracePath = path.join(getOrchestrationDir(workspacePath), "agent_trace.jsonl") + const entries: AgentTraceEntry[] = [] + + if (!fs.existsSync(tracePath)) { + return entries + } + + const content = fs.readFileSync(tracePath, "utf-8") + const lines = content.split("\n").filter((line) => line.trim()) + + for (const line of lines) { + try { + const entry = JSON.parse(line) as AgentTraceEntry + // Check if this entry is related to the intent + for (const file of entry.files) { + for (const conv of file.conversations) { + for (const related of conv.related) { + if (related.value === intentId) { + entries.push(entry) + break + } + } + } + } + } catch { + // Skip malformed lines + } + } + + return entries +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000000..69f212a4bd --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,73 @@ +/** + * Hook System Integration + * + * This module provides functions to integrate the hook system + * with the tool execution pipeline. + */ + +import { getHookEngine } from "./HookEngine" +import { logTrace, classifyMutation } from "./TraceLogger" +import type { HookContext, MutationClass } from "./types" + +/** + * Execute a tool with Pre-Hook and Post-Hook + * + * @param context - The hook context + * @param executeTool - The actual tool execution function + * @param toolResult - The result of the tool execution (for Post-Hook) + * @param mutationClass - Classification of the mutation + * @returns The result of the tool execution, or an error if blocked + */ +export async function executeWithHooks( + context: HookContext, + executeTool: () => Promise, + toolResult: string, + mutationClass: MutationClass = "UNKNOWN", +): Promise<{ success: boolean; error?: string }> { + const hookEngine = getHookEngine() + + // Pre-Hook: Validate and potentially modify the execution + const preResult = await hookEngine.preHook(context) + + if (!preResult.allowed) { + return { + success: false, + error: preResult.errorMessage || "Execution blocked by Pre-Hook", + } + } + + // Execute the tool + try { + await executeTool() + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + + // Post-Hook: Log the trace + const postResult = await hookEngine.postHook(context, toolResult, mutationClass) + + if (!postResult.success) { + console.warn("[HookSystem] Post-Hook warning:", postResult.errorMessage) + } + + return { success: true } +} + +/** + * Initialize the hook engine for a new task + */ +export function initializeHookEngine(workspacePath: string, taskId: string, instanceId: string): void { + const hookEngine = getHookEngine() + hookEngine.initialize(workspacePath, taskId, instanceId) +} + +/** + * Reset the hook engine + */ +export function resetHookEngine(): void { + const hookEngine = getHookEngine() + hookEngine.reset() +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000000..de1b6d16d3 --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,247 @@ +/** + * Intent-Code Traceability Hook System + * + * This module provides the core types and interfaces for the hook system + * that intercepts tool executions to enforce intent context and trace code changes. + */ + +import * as vscode from "vscode" +import * as path from "path" +import * as crypto from "crypto" +import * as fs from "fs" +import * as yaml from "yaml" + +/** + * Represents an active intent in the system + */ +export interface ActiveIntent { + id: string + name: string + status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED" + owned_scope: string[] + constraints: string[] + acceptance_criteria: string[] + created_at: string + updated_at: string +} + +/** + * The active intents data model + */ +export interface ActiveIntentsData { + active_intents: ActiveIntent[] +} + +/** + * Represents a single file modification in the trace + */ +export interface TraceFileEntry { + relative_path: string + conversations: TraceConversation[] +} + +/** + * Represents a conversation/contribution to a file + */ +export interface TraceConversation { + url: string // session_log_id + contributor: { + entity_type: "AI" | "HUMAN" + model_identifier?: string + } + ranges: TraceRange[] + related: TraceRelated[] +} + +/** + * A range of lines with content hash for spatial independence + */ +export interface TraceRange { + start_line: number + end_line: number + content_hash: string +} + +/** + * Related specifications/intents + */ +export interface TraceRelated { + type: "specification" | "intent" | "constraint" + value: string +} + +/** + * A single trace entry in the ledger + */ +export interface AgentTraceEntry { + id: string + timestamp: string + vcs: { + revision_id: string + } + files: TraceFileEntry[] +} + +/** + * Mutation classification for distinguishing refactors from features + */ +export type MutationClass = "AST_REFACTOR" | "INTENT_EVOLUTION" | "DOCUMENTATION" | "UNKNOWN" + +/** + * Hook execution context + */ +export interface HookContext { + taskId: string + instanceId: string + cwd: string + activeIntentId: string | null + toolName: string + toolParams: Record +} + +/** + * Result of a Pre-Hook check + */ +export interface PreHookResult { + allowed: boolean + errorMessage?: string + modifiedParams?: Record + injectedContext?: string +} + +/** + * Result of a Post-Hook operation + */ +export interface PostHookResult { + success: boolean + traceEntry?: AgentTraceEntry + errorMessage?: string +} + +/** + * Tool classification + */ +export type ToolClassification = "SAFE" | "DESTRUCTIVE" | "UNKNOWN" + +/** + * Classification of tools based on their potential impact + */ +export function classifyTool(toolName: string): ToolClassification { + const safeTools = ["read_file", "list_files", "search_files", "codebase_search", "read_command_output"] + + const destructiveTools = [ + "write_to_file", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + "apply_diff", + "execute_command", + "delete_file", + ] + + if (safeTools.includes(toolName)) return "SAFE" + if (destructiveTools.includes(toolName)) return "DESTRUCTIVE" + return "UNKNOWN" +} + +/** + * Compute SHA-256 hash of content for spatial independence + */ +export function computeContentHash(content: string): string { + const hash = crypto.createHash("sha256") + hash.update(content) + return `sha256:${hash.digest("hex")}` +} + +/** + * Get the workspace orchestration directory path + */ +export function getOrchestrationDir(workspacePath: string): string { + return path.join(workspacePath, ".orchestration") +} + +/** + * Ensure the orchestration directory exists + */ +export async function ensureOrchestrationDir(workspacePath: string): Promise { + const dir = getOrchestrationDir(workspacePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + return dir +} + +/** + * Load active intents from YAML file + */ +export async function loadActiveIntents(workspacePath: string): Promise { + const filePath = path.join(getOrchestrationDir(workspacePath), "active_intents.yaml") + try { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, "utf-8") + return yaml.parse(content) as ActiveIntentsData + } + } catch (error) { + console.error("[HookSystem] Failed to load active_intents.yaml:", error) + } + return null +} + +/** + * Save active intents to YAML file + */ +export async function saveActiveIntents(workspacePath: string, data: ActiveIntentsData): Promise { + const dir = await ensureOrchestrationDir(workspacePath) + const filePath = path.join(dir, "active_intents.yaml") + const content = yaml.stringify(data, { indent: 2 }) + fs.writeFileSync(filePath, content, "utf-8") +} + +/** + * Get a specific intent by ID + */ +export function getIntentById(data: ActiveIntentsData, intentId: string): ActiveIntent | null { + return data.active_intents.find((intent) => intent.id === intentId) || null +} + +/** + * Check if a file path matches the intent's owned scope + */ +export function isFileInScope(filePath: string, scopePatterns: string[]): boolean { + // Simple glob matching - can be enhanced with proper glob library + for (const pattern of scopePatterns) { + // Convert glob pattern to regex + const regexPattern = pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\?/g, ".") + + const regex = new RegExp(`^${regexPattern}$`) + if (regex.test(filePath)) { + return true + } + } + return false +} + +/** + * Get current git revision ID + */ +export function getGitRevision(workspacePath: string): string { + try { + // This is a simplified version - in production would use simple-git + const headPath = path.join(workspacePath, ".git", "HEAD") + if (fs.existsSync(headPath)) { + const headContent = fs.readFileSync(headPath, "utf-8").trim() + if (headContent.startsWith("ref: ")) { + const refPath = path.join(workspacePath, ".git", headContent.slice(5)) + if (fs.existsSync(refPath)) { + return fs.readFileSync(refPath, "utf-8").trim().slice(0, 7) + } + } + return headContent.slice(0, 7) + } + } catch (error) { + console.error("[HookSystem] Failed to get git revision:", error) + } + return "unknown" +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba69361..d9c85326fc 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -115,6 +115,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + select_active_intent: { intent_id: string } // Add more tools as they are migrated to native protocol } @@ -289,6 +290,7 @@ export const TOOL_DISPLAY_NAMES: Record = { skill: "load skill", generate_image: "generate images", custom_tool: "use custom tools", + select_active_intent: "select active intent", } as const // Define available tool groups. @@ -321,6 +323,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "select_active_intent", ] as const /**