diff --git a/.orchestration/CLAUDE.md b/.orchestration/CLAUDE.md new file mode 100644 index 00000000000..4cabb574235 --- /dev/null +++ b/.orchestration/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md + +# Shared Brain for Roo Code Agent Sessions + +## Project: AI-Native IDE – Week 1 + +### Lessons Learned + +- PreHook enforces scope and intent validation. +- PostHook logs AST diffs, content hashes, and mutation class. +- Session IDs and contributor model must be tied dynamically to each agent instance. +- Active intents are required for agent execution; context injection is mandatory. +- Parallel agents must respect content hashes to prevent stale file overwrites. + +### Architectural Notes + +- Hooks isolated and composable. +- agent_trace.jsonl stores intent → file → hash → mutation_class. +- Future: integrate real Git SHA for vcs.revision_id. +- Future: append lessons automatically when linter/test fails. diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..7c33b148a12 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,34 @@ +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 with Basic Auth" + acceptance_criteria: + - "Unit tests in tests/auth/ pass" + + - id: "INT-002" + name: "Refactor Billing Service" + status: "IN_PROGRESS" + owned_scope: + - "src/billing/**" + constraints: + - "Preserve API endpoints" + - "Maintain decimal precision" + acceptance_criteria: + - "Integration tests in tests/billing/ pass" + + - id: "INT-003" + name: "Add Logging Middleware" + status: "IN_PROGRESS" + owned_scope: + - "src/middleware/logging.ts" + constraints: + - "Log only request metadata" + - "Must be async non-blocking" + acceptance_criteria: + - "Logs appear in console without delaying response" diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 00000000000..6a14bfa83de --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1,3 @@ +{"id":"43b95d93-fcc5-4480-b80e-c033f9e9b11b","timestamp":"2026-02-21T00:00:00.000Z","vcs":{"revision_id":"local-dev"},"files":[{"relative_path":"src/core/assistant-message/presentAssistantMessage.ts","conversations":[{"url":"session:local:manual-entry","contributor":{"entity_type":"AI","model_identifier":"claude-3-5-sonnet"},"ranges":[{"start_line":1,"end_line":1069,"content_hash":"sha256:209ad21a8df7e2aa03bde9ea54de60bce13147ea352c700f89229d1f43c5407f"}],"related":[{"type":"specification","value":"INT-001"}],"mutation_class":"AST_REFACTOR"}]}]} +{"id":"a451fa4e-65a4-4f8f-ac20-0839a389bd11","timestamp":"2026-02-21T16:52:50Z","vcs":{"revision_id":"local-dev"},"files":[{"relative_path":"src/hooks/preHook.ts","conversations":[{"url":"session:local:manual-entry","contributor":{"entity_type":"AI","model_identifier":"claude-3-5-sonnet"},"ranges":[{"start_line":1,"end_line":77,"content_hash":"sha256:15af55ba540fe35138544976f89a98469717fb72b50e1555a2f935955a578e8f"}],"related":[{"type":"specification","value":"INT-002"}],"mutation_class":"AST_REFACTOR"}]}]} +{"id":"ff8c2018-c345-4270-af94-80c8e4a3bcc1","timestamp":"2026-02-21T16:52:50Z","vcs":{"revision_id":"local-dev"},"files":[{"relative_path":"src/hooks/postHook.ts","conversations":[{"url":"session:local:manual-entry","contributor":{"entity_type":"AI","model_identifier":"claude-3-5-sonnet"},"ranges":[{"start_line":1,"end_line":126,"content_hash":"sha256:ffd9f315c486988097be84aaca3057bece77fb65e8af95eb5102fa75770e6481"}],"related":[{"type":"specification","value":"INT-003"}],"mutation_class":"AST_REFACTOR"}]}]} diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 00000000000..eb75e9bab92 --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,5 @@ +# Intent Map - Spatial Layout + +- INT-001: src/auth/jwt.ts, src/middleware/jwt.ts +- INT-002: src/billing/service.ts, src/billing/utils.ts +- INT-003: src/middleware/logging.ts diff --git a/ARCHITECTURE_NOTES.md b/ARCHITECTURE_NOTES.md new file mode 100644 index 00000000000..ec2d63cac1c --- /dev/null +++ b/ARCHITECTURE_NOTES.md @@ -0,0 +1,38 @@ +# Roo Code Hook Architecture Notes + +## Entry Points + +- **execute_command**: Handles all tool execution requests from the agent. +- **write_file**: Performs all codebase mutations initiated by the agent. +- These are the primary points where the hook engine intercepts and enforces governance. + +## Hook Engine + +- **preHook(intentId)**: + - Validates the selected intent against `active_intents.yaml`. + - Injects intent-specific constraints and owned scope into the agent context. + - Blocks execution if no valid intent is selected. +- **postHook(intentId, filePath, content)**: + - Computes SHA-256 hash of the modified code to ensure **spatial independence**. + - Appends a structured entry to `.orchestration/agent_trace.jsonl`, linking the change back to the business intent. + - Supports multiple intents and can track simultaneous agent actions. + +## Orchestration Folder + +- **active_intents.yaml**: Stores all high-level business intents, their status, constraints, and acceptance criteria. +- **agent_trace.jsonl**: Append-only ledger capturing all mutating actions, with intent mapping and content hashes. +- **intent_map.md**: Maps business intents to physical files and AST nodes for traceability and future planning. + +## Demo Flow + +1. User selects **INT-001** (e.g., JWT Authentication Migration). +2. **preHook** injects constraints and scope for the selected intent. +3. Agent executes `write_file` to modify code (e.g., `src/auth/jwt.ts`). +4. **postHook** records the file content hash and updates `agent_trace.jsonl`. +5. Repeat for other intents (e.g., INT-002 Billing Refactor, INT-003 Logging Middleware) to demonstrate parallel intent handling. + +**Key Features Demonstrated:** + +- **Traceability:** Every code change is linked to a specific intent. +- **Governance:** Hooks enforce scope and constraints before code is written. +- **Scalability:** Multiple intents can be managed concurrently with consistent context injection and trace logging. diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..be42c5c6075 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -13,7 +13,8 @@ import type { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../sha import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" - +import { preWriteHook } from "../../hooks/preHook" +import { postWriteHook } from "../../hooks/postHook" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" @@ -58,6 +59,66 @@ import { sanitizeToolUseId } from "../../utils/tool-id" * as it becomes available. */ +async function runWithHooks( + cline: Task, + block: ToolUse, + handler: ToolHandler, + options?: { checkpoint?: boolean }, +) { + if (options?.checkpoint) await checkpointSaveAndMark(cline) + + // ===== BRIDGE START ===== + // convert Roo Code block/agent info to what your hooks expect + const filePath = block.filePath || block.target // actual file being edited + const intentId = block.intentId || cline.activeIntentId // pick from your agent context + const sessionId = cline.sessionId || crypto.randomUUID() // per-session ID + const contributorModel = cline.modelIdentifier || "claude-3-5-sonnet" // agent model name + // ===== BRIDGE END ===== + + // pre-hook can access cline and block + await preWriteHook(filePath, intentId) + + // Keep helpers in scope so handlers still compile + const askApproval = async ( + type: ClineAsk, + partialMessage?: string, + progressStatus?: ToolProgressStatus, + isProtected?: boolean, + ) => { + const { response, text, images } = await cline.ask( + type, + partialMessage, + false, + progressStatus, + isProtected || false, + ) + if (response !== "yesButtonClicked") { + if (text) { + await cline.say("user_feedback", text, images) + pushToolResult(formatResponse.toolDeniedWithFeedback(text), images) + } else { + pushToolResult(formatResponse.toolDenied()) + } + return false + } + return true + } + + const pushToolResult = (content: ToolResponse, images?: string[]) => { + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: block.id, content }) + if (images) cline.userMessageContent.push(...images) + } + + try { + await handler.handle(cline, block, { askApproval, handleError, pushToolResult }) + + postWriteHook(filePath, intentId, sessionId, contributorModel, "SUCCESS") + } catch (err) { + postWriteHook(filePath, intentId, sessionId, contributorModel, "FAILED", err) + throw err + } +} + export async function presentAssistantMessage(cline: Task) { if (cline.abort) { throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`) @@ -326,63 +387,81 @@ export async function presentAssistantMessage(cline: Task) { const toolDescription = (): string => { switch (block.name) { - case "execute_command": - return `[${block.name} for '${block.params.command}']` - case "read_file": - // Prefer native typed args when available; fall back to legacy params - // Check if nativeArgs exists (native protocol) - if (block.nativeArgs) { - return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) - } - return readFileTool.getReadFileToolDescription(block.name, block.params) case "write_to_file": - return `[${block.name} for '${block.params.path}']` + await runWithHooks(cline, block as ToolUse<"write_to_file">, writeToFileTool, { + checkpoint: true, + }) + break + case "update_todo_list": + await runWithHooks(cline, block as ToolUse<"update_todo_list">, updateTodoListTool) + break case "apply_diff": - // Native-only: tool args are structured (no XML payloads). - return block.params?.path ? `[${block.name} for '${block.params.path}']` : `[${block.name}]` - case "search_files": - return `[${block.name} for '${block.params.regex}'${ - block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" - }]` + await runWithHooks(cline, block as ToolUse<"apply_diff">, applyDiffToolClass, { + checkpoint: true, + }) + break case "edit": case "search_and_replace": - return `[${block.name} for '${block.params.file_path}']` + await runWithHooks(cline, block as ToolUse<"edit">, editTool, { checkpoint: true }) + break case "search_replace": - return `[${block.name} for '${block.params.file_path}']` + await runWithHooks(cline, block as ToolUse<"search_replace">, searchReplaceTool, { + checkpoint: true, + }) + break case "edit_file": - return `[${block.name} for '${block.params.file_path}']` + await runWithHooks(cline, block as ToolUse<"edit_file">, editFileTool, { checkpoint: true }) + break case "apply_patch": - return `[${block.name}]` + await runWithHooks(cline, block as ToolUse<"apply_patch">, applyPatchTool, { checkpoint: true }) + break + case "read_file": + await runWithHooks(cline, block as ToolUse<"read_file">, readFileTool) + break case "list_files": - return `[${block.name} for '${block.params.path}']` + await runWithHooks(cline, block as ToolUse<"list_files">, listFilesTool) + break + case "codebase_search": + await runWithHooks(cline, block as ToolUse<"codebase_search">, codebaseSearchTool) + break + case "search_files": + await runWithHooks(cline, block as ToolUse<"search_files">, searchFilesTool) + break + case "execute_command": + await runWithHooks(cline, block as ToolUse<"execute_command">, executeCommandTool) + break + case "read_command_output": + await runWithHooks(cline, block as ToolUse<"read_command_output">, readCommandOutputTool) + break case "use_mcp_tool": - return `[${block.name} for '${block.params.server_name}']` + await runWithHooks(cline, block as ToolUse<"use_mcp_tool">, useMcpToolTool) + break case "access_mcp_resource": - return `[${block.name} for '${block.params.server_name}']` + await runWithHooks(cline, block as ToolUse<"access_mcp_resource">, accessMcpResourceTool) + break case "ask_followup_question": - return `[${block.name} for '${block.params.question}']` - case "attempt_completion": - return `[${block.name}]` + await runWithHooks(cline, block as ToolUse<"ask_followup_question">, askFollowupQuestionTool) + break case "switch_mode": - return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` - case "codebase_search": - return `[${block.name} for '${block.params.query}']` - case "read_command_output": - return `[${block.name} for '${block.params.artifact_id}']` - case "update_todo_list": - return `[${block.name}]` - case "new_task": { - const mode = block.params.mode ?? defaultModeSlug - const message = block.params.message ?? "(no message)" - const modeName = getModeBySlug(mode, customModes)?.name ?? mode - return `[${block.name} in ${modeName} mode: '${message}']` - } + await runWithHooks(cline, block as ToolUse<"switch_mode">, switchModeTool) + break + case "new_task": + await runWithHooks(cline, block as ToolUse<"new_task">, newTaskTool, { checkpoint: true }) + break + case "attempt_completion": + await runWithHooks(cline, block as ToolUse<"attempt_completion">, attemptCompletionTool) + break case "run_slash_command": - return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` + await runWithHooks(cline, block as ToolUse<"run_slash_command">, runSlashCommandTool) + break case "skill": - return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` + await runWithHooks(cline, block as ToolUse<"skill">, skillTool) + break case "generate_image": - return `[${block.name} for '${block.params.path}']` + await runWithHooks(cline, block as ToolUse<"generate_image">, generateImageTool, { + checkpoint: true, + }) + break default: return `[${block.name}]` } @@ -677,12 +756,7 @@ export async function presentAssistantMessage(cline: Task) { switch (block.name) { case "write_to_file": - await checkpointSaveAndMark(cline) - await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { - askApproval, - handleError, - pushToolResult, - }) + await runWithHooks(cline, block as ToolUse<"write_to_file">, writeToFileTool, { checkpoint: true }) break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac3..5f693f915d9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1,10 +1,10 @@ import * as path from "path" import * as vscode from "vscode" import os from "os" -import crypto from "crypto" import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" - +import * as crypto from "crypto" +import * as fs from "fs/promises" import { AskIgnoredError } from "./AskIgnoredError" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/hooks/__tests__/preHook.spec.ts b/src/hooks/__tests__/preHook.spec.ts new file mode 100644 index 00000000000..8750e63edb4 --- /dev/null +++ b/src/hooks/__tests__/preHook.spec.ts @@ -0,0 +1,143 @@ +import { loadIntents, preWriteHook, Intent } from "../preHook" +import * as fs from "fs" +import * as path from "path" +import * as yaml from "js-yaml" + +// Mock the file system +jest.mock("fs") +jest.mock("path") + +const mockReadFileSync = fs.readFileSync as jest.MockedFunction +const mockResolve = path.resolve as jest.MockedFunction + +describe("preHook", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("loadIntents", () => { + const mockIntents: Intent[] = [ + { + id: "intent-1", + name: "Test Intent 1", + status: "active", + owned_scope: ["src/**/*.ts", "src/auth/**"], + constraints: [], + acceptance_criteria: [], + }, + { + id: "intent-2", + name: "Test Intent 2", + status: "active", + owned_scope: ["src/components/**"], + constraints: [], + acceptance_criteria: [], + }, + ] + + it("should load intents from YAML file successfully", () => { + const yamlContent = { active_intents: mockIntents } + mockReadFileSync.mockReturnValue(yaml.dump(yamlContent)) + mockResolve.mockReturnValue("/path/to/.orchestration/active_intents.yaml") + + const result = loadIntents() + + expect(result).toEqual(mockIntents) + expect(mockReadFileSync).toHaveBeenCalledWith(expect.any(String), "utf-8") + }) + + it("should throw error if file cannot be read", () => { + mockReadFileSync.mockImplementation(() => { + throw new Error("File not found") + }) + mockResolve.mockReturnValue("/path/to/.orchestration/active_intents.yaml") + + expect(() => loadIntents()).toThrow("Failed to load intents: File not found") + }) + + it("should throw error if YAML structure is invalid", () => { + mockReadFileSync.mockReturnValue("invalid yaml content") + mockResolve.mockReturnValue("/path/to/.orchestration/active_intents.yaml") + + expect(() => loadIntents()).toThrow("Invalid YAML structure: missing active_intents array") + }) + + it("should throw error if active_intents array is missing", () => { + const yamlContent = { other_field: "value" } + mockReadFileSync.mockReturnValue(yaml.dump(yamlContent)) + mockResolve.mockReturnValue("/path/to/.orchestration/active_intents.yaml") + + expect(() => loadIntents()).toThrow("Invalid YAML structure: missing active_intents array") + }) + }) + + describe("preWriteHook", () => { + const mockIntents: Intent[] = [ + { + id: "intent-1", + name: "Test Intent 1", + status: "active", + owned_scope: ["src/**/*.ts", "src/auth/**"], + constraints: [], + acceptance_criteria: [], + }, + { + id: "intent-2", + name: "Test Intent 2", + status: "active", + owned_scope: ["src/components/**", "lib/**/*.js"], + constraints: [], + acceptance_criteria: [], + }, + ] + + beforeEach(() => { + const yamlContent = { active_intents: mockIntents } + mockReadFileSync.mockReturnValue(yaml.dump(yamlContent)) + mockResolve.mockReturnValue("/path/to/.orchestration/active_intents.yaml") + }) + + it("should return intent when file path matches scope pattern", () => { + const result = preWriteHook("src/auth/login.ts", "intent-1") + + expect(result).toEqual(mockIntents[0]) + }) + + it("should return intent when file path matches multiple patterns", () => { + const result = preWriteHook("src/components/Button.tsx", "intent-2") + + expect(result).toEqual(mockIntents[1]) + }) + + it("should throw error for invalid intent ID", () => { + expect(() => preWriteHook("src/test.ts", "invalid-intent")).toThrow("Invalid Intent ID: invalid-intent") + }) + + it("should throw error when file path does not match any scope pattern", () => { + expect(() => preWriteHook("src/other/file.ts", "intent-1")).toThrow( + "Scope Violation: Intent 'intent-1' is not authorized to edit 'src/other/file.ts'", + ) + }) + + it("should handle glob patterns with single asterisk correctly", () => { + const result = preWriteHook("src/components/Button.tsx", "intent-2") + expect(result).toEqual(mockIntents[1]) + }) + + it("should handle glob patterns with double asterisk correctly", () => { + const result = preWriteHook("src/auth/login/service.ts", "intent-1") + expect(result).toEqual(mockIntents[0]) + }) + + it("should not match partial paths", () => { + expect(() => preWriteHook("test.ts", "intent-1")).toThrow( + "Scope Violation: Intent 'intent-1' is not authorized to edit 'test.ts'", + ) + }) + + it("should handle root-level patterns", () => { + const result = preWriteHook("lib/utils.js", "intent-2") + expect(result).toEqual(mockIntents[1]) + }) + }) +}) diff --git a/src/hooks/hookEngine.ts b/src/hooks/hookEngine.ts new file mode 100644 index 00000000000..e32381a6d58 --- /dev/null +++ b/src/hooks/hookEngine.ts @@ -0,0 +1,26 @@ +import { preWriteHook } from "./preHook" +import { postWriteHook } from "./postHook" +import fs from "fs" + +// Wrap the write_file tool +export async function writeFileWithHooks( + filePath: string, + content: string, + intentId: string, + sessionId: string, + contributorModel: string, +) { + // PREHOOK: validate intent + scope + const intent = preWriteHook(filePath, intentId) + + // backup old file for AST diff + if (fs.existsSync(filePath)) { + fs.copyFileSync(filePath, filePath + ".bak") + } + + // ACTUAL WRITE + fs.writeFileSync(filePath, content, "utf-8") + + // POSTHOOK: trace log + postWriteHook(filePath, intent, sessionId, contributorModel) +} diff --git a/src/hooks/postHook.ts b/src/hooks/postHook.ts new file mode 100644 index 00000000000..9ee6dd7570d --- /dev/null +++ b/src/hooks/postHook.ts @@ -0,0 +1,125 @@ +import fs from "fs" +import crypto from "crypto" +import path from "path" +import { parse } from "@babel/parser" +import traverse, { NodePath } from "@babel/traverse" +import * as t from "@babel/types" +import { Intent } from "./preHook" + +export function computeHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex") +} + +export function countAstNodes(ast: t.File): number { + let count = 0 + traverse(ast, { + enter() { + count++ + }, + }) + return count +} + +export function detectMutationClass(oldContent: string, newContent: string): "AST_REFACTOR" | "INTENT_EVOLUTION" { + const oldAST = parse(oldContent, { + sourceType: "module", + plugins: ["typescript"], + }) + + const newAST = parse(newContent, { + sourceType: "module", + plugins: ["typescript"], + }) + + // compute simple metrics for a more mathematical decision + const oldNodes = countAstNodes(oldAST) + const newNodes = countAstNodes(newAST) + + // fall back to function count if node counts are both zero + let oldFunctions = 0 + let newFunctions = 0 + traverse(oldAST, { + FunctionDeclaration(_path: NodePath) { + oldFunctions++ + }, + }) + traverse(newAST, { + FunctionDeclaration(_path: NodePath) { + newFunctions++ + }, + }) + + // If the relative change in node count is small (<10%), treat as refactor + const maxNodes = Math.max(oldNodes, newNodes, 1) + const ratio = Math.abs(oldNodes - newNodes) / maxNodes + if (ratio < 0.1) { + return "AST_REFACTOR" + } + + // if node count is identical but functions changed, still a refactor + if (oldNodes === newNodes && oldFunctions === newFunctions) { + return "AST_REFACTOR" + } + + return "INTENT_EVOLUTION" +} + +export function postWriteHook( + filePath: string, + intent: Intent, + sessionId: string, + contributorModel: string, + status: "SUCCESS" | "FAILED", + error?: unknown, +) { + const orchestrationDir = path.resolve(".orchestration") + if (!fs.existsSync(orchestrationDir)) { + fs.mkdirSync(orchestrationDir) + } + + const tracePath = path.join(orchestrationDir, "agent_trace.jsonl") + + const newContent = fs.readFileSync(filePath, "utf-8") + const contentHash = computeHash(newContent) + + const backupPath = filePath + ".bak" + const oldContent = fs.existsSync(backupPath) ? fs.readFileSync(backupPath, "utf-8") : newContent + + const mutationClass = detectMutationClass(oldContent, newContent) + + const traceEntry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + vcs: { revision_id: "local-dev" }, // replace later with real git SHA + files: [ + { + relative_path: filePath, + conversations: [ + { + url: sessionId, + contributor: { + entity_type: "AI", + model_identifier: contributorModel, + }, + ranges: [ + { + start_line: 1, + end_line: newContent.split("\n").length, + content_hash: `sha256:${contentHash}`, + }, + ], + related: [ + { + type: "specification", + value: intent.id, + }, + ], + mutation_class: mutationClass, + }, + ], + }, + ], + } + + fs.appendFileSync(tracePath, JSON.stringify(traceEntry) + "\n") +} diff --git a/src/hooks/preHook.ts b/src/hooks/preHook.ts new file mode 100644 index 00000000000..2bd08807b56 --- /dev/null +++ b/src/hooks/preHook.ts @@ -0,0 +1,77 @@ +import fs from "fs" +import yaml from "js-yaml" +import path from "path" +import { matchGlobPattern } from "../utils/glob" + +/** + * Represents an intent with its associated metadata and constraints. + */ +export interface Intent { + /** Unique identifier for the intent */ + id: string + /** Human-readable name of the intent */ + name: string + /** Current status of the intent (e.g., 'active', 'completed') */ + status: string + /** File path patterns that this intent is authorized to edit */ + owned_scope: string[] + /** Additional constraints for the intent */ + constraints: string[] + /** Acceptance criteria for the intent */ + acceptance_criteria: string[] +} + +/** + * Loads intents from the active_intents.yaml file. + * @returns Array of Intent objects + * @throws Error if file cannot be read or parsed + */ +export function loadIntents(): Intent[] { + const file = path.resolve(".orchestration/active_intents.yaml") + + try { + const content = fs.readFileSync(file, "utf-8") + const parsed = yaml.load(content) as { active_intents: Intent[] } + + if (!parsed || !parsed.active_intents) { + throw new Error("Invalid YAML structure: missing active_intents array") + } + + return parsed.active_intents + } catch (error) { + throw new Error(`Failed to load intents: ${error instanceof Error ? error.message : "Unknown error"}`) + } +} + +/** + * Pre-write hook that validates intent authorization before file modifications. + * @param filePath - Path of the file to be modified + * @param intentId - ID of the intent requesting the modification + * @returns The validated Intent object + * @throws Error if intent is invalid or scope violation occurs + */ +export function preWriteHook(filePath: string, intentId: string): Intent { + const intents = loadIntents() + const intent = intents.find((i) => i.id === intentId) + + if (!intent) { + throw new Error(`Invalid Intent ID: ${intentId}`) + } + + // Validate scope authorization + const isAuthorized = intent.owned_scope.some((pattern) => { + try { + return matchGlobPattern(pattern, filePath) + } catch (error) { + throw new Error( + `Pattern validation failed for pattern '${pattern}': ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + }) + + if (!isAuthorized) { + throw new Error(`Scope Violation: Intent '${intentId}' is not authorized to edit '${filePath}'`) + } + + return intent +}