mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
Merge 97d30d9a60 into 7adbfec2a4
This commit is contained in:
commit
1b496add8b
11 changed files with 598 additions and 53 deletions
20
.orchestration/CLAUDE.md
Normal file
20
.orchestration/CLAUDE.md
Normal file
|
|
@ -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.
|
||||
34
.orchestration/active_intents.yaml
Normal file
34
.orchestration/active_intents.yaml
Normal file
|
|
@ -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"
|
||||
3
.orchestration/agent_trace.jsonl
Normal file
3
.orchestration/agent_trace.jsonl
Normal file
|
|
@ -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"}]}]}
|
||||
5
.orchestration/intent_map.md
Normal file
5
.orchestration/intent_map.md
Normal file
|
|
@ -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
|
||||
38
ARCHITECTURE_NOTES.md
Normal file
38
ARCHITECTURE_NOTES.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<T extends ToolName>(
|
||||
cline: Task,
|
||||
block: ToolUse<T>,
|
||||
handler: ToolHandler<T>,
|
||||
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}']`
|
||||
case "use_mcp_tool":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "access_mcp_resource":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "ask_followup_question":
|
||||
return `[${block.name} for '${block.params.question}']`
|
||||
case "attempt_completion":
|
||||
return `[${block.name}]`
|
||||
case "switch_mode":
|
||||
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
|
||||
await runWithHooks(cline, block as ToolUse<"list_files">, listFilesTool)
|
||||
break
|
||||
case "codebase_search":
|
||||
return `[${block.name} for '${block.params.query}']`
|
||||
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":
|
||||
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<"read_command_output">, readCommandOutputTool)
|
||||
break
|
||||
case "use_mcp_tool":
|
||||
await runWithHooks(cline, block as ToolUse<"use_mcp_tool">, useMcpToolTool)
|
||||
break
|
||||
case "access_mcp_resource":
|
||||
await runWithHooks(cline, block as ToolUse<"access_mcp_resource">, accessMcpResourceTool)
|
||||
break
|
||||
case "ask_followup_question":
|
||||
await runWithHooks(cline, block as ToolUse<"ask_followup_question">, askFollowupQuestionTool)
|
||||
break
|
||||
case "switch_mode":
|
||||
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">, {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
26
src/hooks/hookEngine.ts
Normal file
26
src/hooks/hookEngine.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
125
src/hooks/postHook.ts
Normal file
125
src/hooks/postHook.ts
Normal file
|
|
@ -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<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,
|
||||
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")
|
||||
}
|
||||
77
src/hooks/preHook.ts
Normal file
77
src/hooks/preHook.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue