mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
commit
95fd3d0179
12 changed files with 516 additions and 0 deletions
21
src/hooks/content-hash.ts
Normal file
21
src/hooks/content-hash.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
const HASH_PREFIX = "sha256:"
|
||||
|
||||
/**
|
||||
* Compute a SHA-256 content hash for spatial independence.
|
||||
* If lines move, the hash of the content block remains valid.
|
||||
*/
|
||||
export function contentHash(content: string): string {
|
||||
const hash = crypto.createHash("sha256").update(content, "utf8").digest("hex")
|
||||
return `${HASH_PREFIX}${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a logical code block (e.g. by line range) and return its hash.
|
||||
*/
|
||||
export function contentHashForRange(fullContent: string, startLine: number, endLine: number): string {
|
||||
const lines = fullContent.split("\n")
|
||||
const slice = lines.slice(Math.max(0, startLine - 1), endLine).join("\n")
|
||||
return contentHash(slice)
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import yaml from "yaml"
|
||||
|
||||
import type { IntentContext, ActiveIntentsDoc } from "./types"
|
||||
|
||||
const ORCHESTRATION_DIR = ".orchestration"
|
||||
const ACTIVE_INTENTS_FILE = "active_intents.yaml"
|
||||
const AGENT_TRACE_FILE = "agent_trace.jsonl"
|
||||
|
||||
/**
|
||||
* Loads intent context from .orchestration/active_intents.yaml for the given intent ID.
|
||||
* Used by the Pre-Hook when the agent calls select_active_intent.
|
||||
*/
|
||||
export async function loadIntentContext(cwd: string, intentId: string): Promise<IntentContext | null> {
|
||||
const filePath = path.join(cwd, ORCHESTRATION_DIR, ACTIVE_INTENTS_FILE)
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8")
|
||||
const doc = yaml.parse(raw) as ActiveIntentsDoc
|
||||
const intent = doc?.active_intents?.find((i) => i.id === intentId)
|
||||
if (!intent) return null
|
||||
return {
|
||||
id: intent.id,
|
||||
name: intent.name,
|
||||
status: intent.status,
|
||||
constraints: intent.constraints ?? [],
|
||||
owned_scope: intent.owned_scope ?? [],
|
||||
acceptance_criteria: intent.acceptance_criteria,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an XML block to inject as the tool result for select_active_intent.
|
||||
*/
|
||||
export function buildIntentContextXml(context: IntentContext): string {
|
||||
const constraintsXml =
|
||||
context.constraints.length > 0
|
||||
? context.constraints.map((c) => ` <constraint>${escapeXml(c)}</constraint>`).join("\n")
|
||||
: " <constraint>None specified</constraint>"
|
||||
const scopeXml =
|
||||
context.owned_scope.length > 0
|
||||
? context.owned_scope.map((s) => ` <scope>${escapeXml(s)}</scope>`).join("\n")
|
||||
: " <scope>No scope restriction</scope>"
|
||||
const criteriaXml =
|
||||
(context.acceptance_criteria?.length ?? 0 > 0)
|
||||
? context.acceptance_criteria!.map((a) => ` <criterion>${escapeXml(a)}</criterion>`).join("\n")
|
||||
: " <criterion>None specified</criterion>"
|
||||
|
||||
return `<intent_context>
|
||||
<id>${escapeXml(context.id)}</id>
|
||||
<name>${escapeXml(context.name)}</name>
|
||||
<status>${escapeXml(context.status)}</status>
|
||||
<constraints>
|
||||
${constraintsXml}
|
||||
</constraints>
|
||||
<owned_scope>
|
||||
${scopeXml}
|
||||
</owned_scope>
|
||||
<acceptance_criteria>
|
||||
${criteriaXml}
|
||||
</acceptance_criteria>
|
||||
</intent_context>`
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read recent agent trace lines for an intent (optional: for "related history" in context).
|
||||
*/
|
||||
export async function readRecentTraceForIntent(cwd: string, intentId: string, limit: number = 20): Promise<string[]> {
|
||||
const filePath = path.join(cwd, ORCHESTRATION_DIR, AGENT_TRACE_FILE)
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8")
|
||||
const lines = raw.trim().split("\n").filter(Boolean)
|
||||
const related: string[] = []
|
||||
for (let i = lines.length - 1; i >= 0 && related.length < limit; i--) {
|
||||
const entry = JSON.parse(lines[i]) as {
|
||||
files?: Array<{ conversations?: Array<{ related?: Array<{ value: string }> }> }>
|
||||
}
|
||||
for (const f of entry.files ?? []) {
|
||||
for (const conv of f.conversations ?? []) {
|
||||
for (const r of conv.related ?? []) {
|
||||
if (r.value === intentId) {
|
||||
related.push(lines[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return related
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Orchestration hooks for Intent–Code traceability (TRP1 Challenge).
|
||||
* Clean middleware: Pre-Hook (context + scope + gatekeeper), Post-Hook (agent_trace.jsonl).
|
||||
*/
|
||||
|
||||
export * from "./types"
|
||||
export * from "./content-hash"
|
||||
export * from "./context-loader"
|
||||
export * from "./scope"
|
||||
export * from "./pre-hook"
|
||||
export * from "./post-hook"
|
||||
export * from "./middleware"
|
||||
export * from "./select-active-intent-tool"
|
||||
6
src/hooks/intent-prompt-snippet.ts
Normal file
6
src/hooks/intent-prompt-snippet.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* System prompt snippet to enforce the Intent-Driven protocol.
|
||||
* Prepend or inject this when orchestration is enabled so the agent
|
||||
* must call select_active_intent before writing code.
|
||||
*/
|
||||
export const INTENT_DRIVEN_PROMPT_SNIPPET = `You are an Intent-Driven Architect. You CANNOT write code immediately. Your first action MUST be to analyze the user request, identify which requirement or task it maps to, and call select_active_intent(intent_id) with a valid ID from .orchestration/active_intents.yaml to load the necessary context. Only after you receive the intent context (constraints, scope, acceptance criteria) may you proceed to use write_to_file or other editing tools. If no intent matches the request, you must ask the user to add one to active_intents.yaml or clarify the task.`
|
||||
55
src/hooks/middleware.ts
Normal file
55
src/hooks/middleware.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { HookResult } from "./types"
|
||||
import type { PreHook } from "./pre-hook"
|
||||
import { appendAgentTrace } from "./post-hook"
|
||||
|
||||
export interface HookMiddlewareOptions {
|
||||
preHook: PreHook
|
||||
getActiveIntentId: () => string | null
|
||||
getCwd: () => string
|
||||
getSessionLogId?: () => string | undefined
|
||||
getModelId?: () => string | undefined
|
||||
getVcsRevisionId?: () => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook Engine: strict middleware boundary around tool execution.
|
||||
* 1. Pre-Hook: intercept, validate intent/scope, optionally inject result for select_active_intent.
|
||||
* 2. Execute: delegate to original tool (caller performs this).
|
||||
* 3. Post-Hook: on write_to_file success, append to agent_trace.jsonl.
|
||||
*/
|
||||
export class HookMiddleware {
|
||||
constructor(private options: HookMiddlewareOptions) {}
|
||||
|
||||
/**
|
||||
* Run pre-hook only. Returns HookResult; if blocked, caller must not execute the tool
|
||||
* and should push toolResult with result.error. If injectResult is set, caller should
|
||||
* push that as the tool result (for select_active_intent) and skip calling the real tool.
|
||||
*/
|
||||
async preToolUse(toolName: string, params: Record<string, unknown>): Promise<HookResult> {
|
||||
return this.options.preHook.intercept(toolName, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run post-hook after a successful write_to_file. Call this from the host after
|
||||
* the file has been written.
|
||||
*/
|
||||
async postToolUse(toolName: string, params: Record<string, unknown>, _result: unknown): Promise<void> {
|
||||
if (toolName !== "write_to_file") return
|
||||
const pathParam = params.path
|
||||
const contentParam = params.content
|
||||
if (typeof pathParam !== "string" || typeof contentParam !== "string") return
|
||||
|
||||
const intentId = this.options.getActiveIntentId()
|
||||
const mutationClass = (params.mutation_class as "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE") ?? "UNKNOWN"
|
||||
|
||||
await appendAgentTrace(this.options.getCwd(), {
|
||||
relativePath: pathParam,
|
||||
content: contentParam,
|
||||
intentId,
|
||||
mutationClass,
|
||||
sessionLogId: this.options.getSessionLogId?.(),
|
||||
modelIdentifier: this.options.getModelId?.(),
|
||||
vcsRevisionId: this.options.getVcsRevisionId?.(),
|
||||
})
|
||||
}
|
||||
}
|
||||
71
src/hooks/post-hook.ts
Normal file
71
src/hooks/post-hook.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
import type { AgentTraceEntry, AgentTraceFileEntry, AgentTraceConversation, MutationClass } from "./types"
|
||||
import { contentHash, contentHashForRange } from "./content-hash"
|
||||
|
||||
const ORCHESTRATION_DIR = ".orchestration"
|
||||
const AGENT_TRACE_FILE = "agent_trace.jsonl"
|
||||
|
||||
export interface PostHookWriteParams {
|
||||
relativePath: string
|
||||
content: string
|
||||
intentId: string | null
|
||||
mutationClass?: MutationClass
|
||||
sessionLogId?: string
|
||||
modelIdentifier?: string
|
||||
vcsRevisionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-Hook: after a successful write_to_file, append an entry to agent_trace.jsonl
|
||||
* linking the file (and content hash) to the intent.
|
||||
*/
|
||||
export async function appendAgentTrace(cwd: string, params: PostHookWriteParams): Promise<void> {
|
||||
const {
|
||||
relativePath,
|
||||
content,
|
||||
intentId,
|
||||
mutationClass = "UNKNOWN",
|
||||
sessionLogId,
|
||||
modelIdentifier = "unknown",
|
||||
vcsRevisionId,
|
||||
} = params
|
||||
|
||||
const dir = path.join(cwd, ORCHESTRATION_DIR)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const tracePath = path.join(dir, AGENT_TRACE_FILE)
|
||||
|
||||
const lines = content.split("\n")
|
||||
const fullRangeHash = contentHash(content)
|
||||
const conversation: AgentTraceConversation = {
|
||||
url: sessionLogId,
|
||||
contributor: { entity_type: "AI", model_identifier: modelIdentifier },
|
||||
ranges: [{ start_line: 1, end_line: lines.length, content_hash: fullRangeHash }],
|
||||
related: intentId ? [{ type: "specification", value: intentId }] : [],
|
||||
}
|
||||
|
||||
const fileEntry: AgentTraceFileEntry = {
|
||||
relative_path: relativePath,
|
||||
conversations: [conversation],
|
||||
}
|
||||
|
||||
const entry: AgentTraceEntry = {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
vcs: vcsRevisionId ? { revision_id: vcsRevisionId } : undefined,
|
||||
files: [fileEntry],
|
||||
}
|
||||
|
||||
const line = JSON.stringify(entry) + "\n"
|
||||
await fs.appendFile(tracePath, line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute content hash for a modified block (e.g. after apply_diff).
|
||||
* Use when you have start_line/end_line and full file content.
|
||||
*/
|
||||
export function computeRangeHash(content: string, startLine: number, endLine: number): string {
|
||||
return contentHashForRange(content, startLine, endLine)
|
||||
}
|
||||
89
src/hooks/pre-hook.ts
Normal file
89
src/hooks/pre-hook.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import * as vscode from "vscode"
|
||||
import path from "path"
|
||||
|
||||
import type { HookResult, IntentContext, MutationClass } from "./types"
|
||||
import { DESTRUCTIVE_TOOLS } from "./types"
|
||||
import { loadIntentContext, buildIntentContextXml } from "./context-loader"
|
||||
import { pathInScope } from "./scope"
|
||||
|
||||
export interface PreHookOptions {
|
||||
cwd: string
|
||||
/** Current intent ID set by select_active_intent (per task/session) */
|
||||
getActiveIntentId: () => string | null
|
||||
setActiveIntentId: (id: string | null) => void
|
||||
/** Optional: path to .intentignore-style patterns (one per line) */
|
||||
intentIgnorePath?: string
|
||||
/** Require intent for destructive tools only; if false, require for all tools */
|
||||
requireIntentForDestructiveOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-Hook: intercepts tool execution to enforce intent context and scope.
|
||||
* - select_active_intent: load context, return XML, set active intent.
|
||||
* - Destructive tools: require active intent; optional HITL; scope check for write_to_file.
|
||||
*/
|
||||
export class PreHook {
|
||||
constructor(private options: PreHookOptions) {}
|
||||
|
||||
async intercept(toolName: string, params: Record<string, unknown>): Promise<HookResult> {
|
||||
const { cwd, getActiveIntentId, setActiveIntentId } = this.options
|
||||
|
||||
// —— select_active_intent: the Handshake ——
|
||||
if (toolName === "select_active_intent") {
|
||||
const intentId = typeof params.intent_id === "string" ? params.intent_id.trim() : null
|
||||
if (!intentId) {
|
||||
return { blocked: true, error: "You must provide a valid intent_id when calling select_active_intent." }
|
||||
}
|
||||
const context = await loadIntentContext(cwd, intentId)
|
||||
if (!context) {
|
||||
return {
|
||||
blocked: true,
|
||||
error: `You must cite a valid active Intent ID. Intent "${intentId}" was not found in .orchestration/active_intents.yaml.`,
|
||||
}
|
||||
}
|
||||
setActiveIntentId(intentId)
|
||||
const xml = buildIntentContextXml(context)
|
||||
return { blocked: false, injectResult: xml }
|
||||
}
|
||||
|
||||
const isDestructive = (DESTRUCTIVE_TOOLS as readonly string[]).includes(toolName)
|
||||
const requireIntent = this.options.requireIntentForDestructiveOnly ? isDestructive : true
|
||||
|
||||
if (requireIntent) {
|
||||
const activeId = getActiveIntentId()
|
||||
if (!activeId) {
|
||||
return {
|
||||
blocked: true,
|
||||
error: "You must select an active intent first. Call select_active_intent(intent_id) with a valid ID from .orchestration/active_intents.yaml before writing code or running destructive commands.",
|
||||
}
|
||||
}
|
||||
|
||||
// Scope enforcement for write_to_file
|
||||
if (toolName === "write_to_file" && params.path) {
|
||||
const relPath = String(params.path)
|
||||
const context = await loadIntentContext(cwd, activeId)
|
||||
if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) {
|
||||
return {
|
||||
blocked: true,
|
||||
error: `Scope Violation: ${activeId} is not authorized to edit "${relPath}". Request scope expansion in active_intents.yaml or choose another intent.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: HITL for destructive tools (can be wired via askApproval in host)
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: prompt for Human-in-the-Loop approval on destructive actions.
|
||||
* Call this from the host when askApproval is invoked for destructive tools.
|
||||
*/
|
||||
static async askApprovalDestructive(toolName: string, message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
vscode.window
|
||||
.showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject")
|
||||
.then((choice) => resolve(choice === "Approve"))
|
||||
})
|
||||
}
|
||||
}
|
||||
39
src/hooks/scope.ts
Normal file
39
src/hooks/scope.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import path from "path"
|
||||
|
||||
/**
|
||||
* Check if a relative file path is within the owned_scope of the active intent.
|
||||
* owned_scope entries are glob-like (e.g. "src/auth/**", "src/middleware/jwt.ts").
|
||||
*/
|
||||
export function pathInScope(relativePath: string, ownedScope: string[], _cwd: string): boolean {
|
||||
if (!ownedScope || ownedScope.length === 0) return true
|
||||
const normalized = path.normalize(relativePath).replace(/\\/g, "/")
|
||||
for (const pattern of ownedScope) {
|
||||
const p = path.normalize(pattern).replace(/\\/g, "/")
|
||||
if (p.endsWith("/**")) {
|
||||
const prefix = p.slice(0, -3)
|
||||
if (normalized === prefix || normalized.startsWith(prefix + "/")) return true
|
||||
} else if (p.includes("*")) {
|
||||
if (simpleGlobMatch(normalized, p)) return true
|
||||
} else {
|
||||
if (normalized === p || normalized.endsWith("/" + p)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function simpleGlobMatch(path: string, pattern: string): boolean {
|
||||
const re = new RegExp(
|
||||
"^" +
|
||||
pattern
|
||||
.split("/")
|
||||
.map((seg) =>
|
||||
seg
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, ".*")
|
||||
.replace(/\*/g, "[^/]*"),
|
||||
)
|
||||
.join("/") +
|
||||
"$",
|
||||
)
|
||||
return re.test(path)
|
||||
}
|
||||
23
src/hooks/select-active-intent-tool.ts
Normal file
23
src/hooks/select-active-intent-tool.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type OpenAI from "openai"
|
||||
|
||||
const SELECT_ACTIVE_INTENT_DESCRIPTION = `Select the active intent (requirement/task) before making code changes. You MUST call this tool first when the user asks you to implement, refactor, or change code. It loads the intent's constraints, scope, and acceptance criteria from .orchestration/active_intents.yaml. Do not write code or use write_to_file until you have called select_active_intent with a valid intent_id.`
|
||||
|
||||
export const selectActiveIntentToolDefinition = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "select_active_intent",
|
||||
description: SELECT_ACTIVE_INTENT_DESCRIPTION,
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
intent_id: {
|
||||
type: "string",
|
||||
description: "The intent ID from .orchestration/active_intents.yaml (e.g. INT-001)",
|
||||
},
|
||||
},
|
||||
required: ["intent_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
} satisfies OpenAI.Chat.ChatCompletionTool
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Orchestration hook types for Intent–Code traceability (TRP1).
|
||||
* Data models and schemas for .orchestration/ sidecar storage.
|
||||
*/
|
||||
|
||||
export interface ActiveIntent {
|
||||
id: string
|
||||
name: string
|
||||
status: "PENDING" | "IN_PROGRESS" | "DONE" | "BLOCKED"
|
||||
owned_scope?: string[]
|
||||
constraints?: string[]
|
||||
acceptance_criteria?: string[]
|
||||
}
|
||||
|
||||
export interface ActiveIntentsDoc {
|
||||
active_intents: ActiveIntent[]
|
||||
}
|
||||
|
||||
export interface IntentContext {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
constraints: string[]
|
||||
owned_scope: string[]
|
||||
acceptance_criteria?: string[]
|
||||
}
|
||||
|
||||
export type MutationClass = "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE" | "UNKNOWN"
|
||||
|
||||
export interface HookResult {
|
||||
blocked: boolean
|
||||
error?: string
|
||||
/** For select_active_intent: XML or text to inject as tool result */
|
||||
injectResult?: string
|
||||
}
|
||||
|
||||
/** Per-file entry in agent_trace.jsonl */
|
||||
export interface AgentTraceFileEntry {
|
||||
relative_path: string
|
||||
conversations: AgentTraceConversation[]
|
||||
}
|
||||
|
||||
export interface AgentTraceConversation {
|
||||
url?: string
|
||||
contributor: {
|
||||
entity_type: "AI" | "human"
|
||||
model_identifier?: string
|
||||
}
|
||||
ranges: Array<{
|
||||
start_line: number
|
||||
end_line: number
|
||||
content_hash: string
|
||||
}>
|
||||
related: Array<{ type: string; value: string }>
|
||||
}
|
||||
|
||||
export interface AgentTraceEntry {
|
||||
id: string
|
||||
timestamp: string
|
||||
vcs?: { revision_id: string }
|
||||
files: AgentTraceFileEntry[]
|
||||
}
|
||||
|
||||
/** Safe = read-only; Destructive = write, delete, execute */
|
||||
export type CommandClass = "safe" | "destructive"
|
||||
|
||||
export const DESTRUCTIVE_TOOLS = [
|
||||
"write_to_file",
|
||||
"apply_diff",
|
||||
"edit",
|
||||
"search_and_replace",
|
||||
"search_replace",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"execute_command",
|
||||
"new_task",
|
||||
"generate_image",
|
||||
] as const
|
||||
|
||||
export const SAFE_TOOLS = [
|
||||
"read_file",
|
||||
"list_files",
|
||||
"codebase_search",
|
||||
"search_files",
|
||||
"read_command_output",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"switch_mode",
|
||||
"update_todo_list",
|
||||
"attempt_completion",
|
||||
"run_slash_command",
|
||||
"skill",
|
||||
] as const
|
||||
Loading…
Add table
Reference in a new issue