mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
the last version of hooks logic
This commit is contained in:
parent
ad27c74afe
commit
72b567649c
4 changed files with 342 additions and 40 deletions
143
src/hooks/__tests__/preHook.spec.ts
Normal file
143
src/hooks/__tests__/preHook.spec.ts
Normal file
|
|
@ -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<typeof fs.readFileSync>
|
||||
const mockResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<t.FunctionDeclaration>) {
|
||||
oldFunctions++
|
||||
},
|
||||
})
|
||||
traverse(newAST, {
|
||||
FunctionDeclaration(_path: NodePath<t.FunctionDeclaration>) {
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue