From 72b567649c225817137ab734b6935ed4757f114a Mon Sep 17 00:00:00 2001 From: rafia-10 Date: Sat, 21 Feb 2026 18:18:39 +0300 Subject: [PATCH] the last version of hooks logic --- src/hooks/__tests__/preHook.spec.ts | 143 ++++++++++++++++++++++++++++ src/hooks/hookEngine.ts | 34 ++++--- src/hooks/postHook.ts | 117 ++++++++++++++++++++--- src/hooks/preHook.ts | 88 ++++++++++++++--- 4 files changed, 342 insertions(+), 40 deletions(-) create mode 100644 src/hooks/__tests__/preHook.spec.ts diff --git a/src/hooks/__tests__/preHook.spec.ts b/src/hooks/__tests__/preHook.spec.ts new file mode 100644 index 0000000000..8750e63edb --- /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 index ceacb6ea75..e32381a6d5 100644 --- a/src/hooks/hookEngine.ts +++ b/src/hooks/hookEngine.ts @@ -1,18 +1,26 @@ -import { preHook } from "./preHook" -import { postHook } from "./postHook" +import { preWriteHook } from "./preHook" +import { postWriteHook } from "./postHook" +import fs from "fs" -export async function runWithHooks(command: string, args: any) { - // Pre-Hook intercept - const preResult = await preHook(command, args) - if (!preResult.allowed) { - throw new Error(`Blocked by PreHook: ${preResult.reason}`) +// 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") } - // Execute actual command - const result = await executeCommand(command, args) + // ACTUAL WRITE + fs.writeFileSync(filePath, content, "utf-8") - // Post-Hook intercept - await postHook(command, args, result) - - return result + // POSTHOOK: trace log + postWriteHook(filePath, intent, sessionId, contributorModel) } diff --git a/src/hooks/postHook.ts b/src/hooks/postHook.ts index 2894d85dca..b9a349c10c 100644 --- a/src/hooks/postHook.ts +++ b/src/hooks/postHook.ts @@ -1,25 +1,118 @@ 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 async function postHook(command: string, args: any, result: any) { - // Compute SHA256 of the content - const content = fs.readFileSync(args.file, "utf8") - const hash = crypto.createHash("sha256").update(content).digest("hex") +export function computeHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex") +} - // Append trace - const trace = { +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) { + 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: "git_sha_placeholder" }, + vcs: { revision_id: "local-dev" }, // replace later with real git SHA files: [ { - relative_path: args.file, - conversations: [], - ranges: [{ start_line: 0, end_line: content.split("\n").length, content_hash: hash }], - related: [{ type: "specification", value: args.intent_id }], + 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(".orchestration/agent_trace.jsonl", JSON.stringify(trace) + "\n") + fs.appendFileSync(tracePath, JSON.stringify(traceEntry) + "\n") } diff --git a/src/hooks/preHook.ts b/src/hooks/preHook.ts index f638210b65..2bd08807b5 100644 --- a/src/hooks/preHook.ts +++ b/src/hooks/preHook.ts @@ -1,19 +1,77 @@ import fs from "fs" import yaml from "js-yaml" -export async function preHook(command: string, args: any) { - // Load active intents - const intents = yaml.load(fs.readFileSync(".orchestration/active_intents.yaml", "utf8")) +import path from "path" +import { matchGlobPattern } from "../utils/glob" - // Ensure the agent selects an intent - if (!args.intent_id || !intents.active_intents.find((i: any) => i.id === args.intent_id)) { - return { allowed: false, reason: "You must cite a valid active Intent ID" } - } - - // Enforce scope - const intent = intents.active_intents.find((i: any) => i.id === args.intent_id) - if (!intent.owned_scope.some((pattern: string) => args.file?.startsWith(pattern.replace("/**", "")))) { - return { allowed: false, reason: `Scope Violation: ${args.intent_id} cannot edit ${args.file}` } - } - - return { allowed: true } +/** + * 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 }