diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 0000000000..3efbaacb78 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,53 @@ +# .orchestration/active_intents.yaml +# The Intent Specification — what work is authorized and why. +# +# This file is the source of truth for governance. +# The Hook Engine reads this file before every mutating tool call. +# Agents MUST call select_active_intent(intent_id) before writing code. +# +# Status values: PENDING | IN_PROGRESS | COMPLETED | CANCELLED + +active_intents: + - id: "INT-001" + name: "Hook Engine Implementation" + status: "IN_PROGRESS" + # Scope: which files/directories this intent is authorized to modify + owned_scope: + - "src/hooks/**" + - "src/core/tools/SelectActiveIntentTool.ts" + - "src/core/assistant-message/presentAssistantMessage.ts" + constraints: + - "Must not modify existing tool behavior — only intercept before/after" + - "Hook failures must never crash the agent — always fail gracefully" + - "Trace records must be append-only — never overwrite agent_trace.jsonl" + acceptance_criteria: + - "Agent is blocked when calling write_to_file without select_active_intent" + - "Agent is blocked when writing outside owned_scope" + - "agent_trace.jsonl is updated after every file write with correct hash" + + - id: "INT-002" + name: "Orchestration Data Model Setup" + status: "IN_PROGRESS" + owned_scope: + - ".orchestration/**" + - "ARCHITECTURE_NOTES.md" + constraints: + - "YAML files must be valid and parseable by the yaml npm package" + - "agent_trace.jsonl must remain append-only JSONL format" + acceptance_criteria: + - "active_intents.yaml exists and is valid YAML" + - "intent_map.md maps all active intents to their owned files" + - "agent_trace.jsonl contains at least one valid trace record" + + - id: "INT-003" + name: "System Prompt Intent Enforcement" + status: "PENDING" + owned_scope: + - "src/core/prompts/**" + - "packages/types/src/tool.ts" + constraints: + - "Must not break existing system prompt structure" + - "Intent instructions must be injected as a new section, not replacing existing ones" + acceptance_criteria: + - "Agent's first action for any code task is always select_active_intent" + - "Agent cannot skip the handshake without being blocked" diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1 @@ + diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 0000000000..340fee5818 --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,61 @@ +# .orchestration/intent_map.md + +# The Spatial Map — which files belong to which intent. + +# + +# This file answers: "Where is the hook engine logic?" + +# It is incrementally updated when INTENT_EVOLUTION occurs. + +# Machine-managed: updated by Post-Hooks when new files are written. + +## INT-001: Hook Engine Implementation + +**Status:** IN_PROGRESS +**Owner:** AI Agent (Builder) + +### Owned Files: + +| File | Role | Last Modified | +| ------------------------------------------------------- | ------------------------------------- | ------------- | +| `src/hooks/types.ts` | Shared types for the hook system | 2026-02-18 | +| `src/hooks/HookEngine.ts` | Singleton middleware engine | 2026-02-18 | +| `src/hooks/preHooks/intentGate.ts` | Pre-hook: blocks tools without intent | 2026-02-18 | +| `src/hooks/preHooks/scopeGuard.ts` | Pre-hook: enforces owned_scope | 2026-02-18 | +| `src/hooks/postHooks/traceLedger.ts` | Post-hook: SHA-256 + JSONL trace | 2026-02-18 | +| `src/hooks/utils/contentHash.ts` | SHA-256 hash utility | 2026-02-18 | +| `src/hooks/utils/intentLoader.ts` | YAML parser + scope matcher | 2026-02-18 | +| `src/hooks/utils/orchestrationPaths.ts` | Path resolution for .orchestration/ | 2026-02-18 | +| `src/core/tools/SelectActiveIntentTool.ts` | The mandatory handshake tool | 2026-02-18 | +| `src/core/assistant-message/presentAssistantMessage.ts` | Wired: pre/post hooks + new tool case | 2026-02-18 | + +--- + +## INT-002: Orchestration Data Model Setup + +**Status:** IN_PROGRESS +**Owner:** AI Agent (Architect) + +### Owned Files: + +| File | Role | Last Modified | +| ------------------------------------ | ------------------------------------ | ------------- | +| `.orchestration/active_intents.yaml` | Intent definitions (source of truth) | 2026-02-18 | +| `.orchestration/agent_trace.jsonl` | Append-only trace ledger | 2026-02-18 | +| `.orchestration/intent_map.md` | This file: spatial intent map | 2026-02-18 | +| `ARCHITECTURE_NOTES.md` | Phase 0 archaeological dig notes | 2026-02-18 | + +--- + +## INT-003: System Prompt Intent Enforcement + +**Status:** PENDING +**Owner:** Not yet assigned + +### Owned Files: + +| File | Role | Last Modified | +| ---------------------------- | ---------------------------- | ------------- | +| `src/core/prompts/system.ts` | Main system prompt assembler | — | +| `packages/types/src/tool.ts` | Tool name registry | 2026-02-18 | diff --git a/ARCHITECTURE_NOTES.md b/ARCHITECTURE_NOTES.md new file mode 100644 index 0000000000..95437de673 --- /dev/null +++ b/ARCHITECTURE_NOTES.md @@ -0,0 +1,310 @@ +# ARCHITECTURE_NOTES.md + +## Phase 0 — The Archaeological Dig into Roo Code + +--- + +## 1. What Is Roo Code? + +Roo Code is a VSCode extension that runs an AI coding agent inside the editor. It is a **monorepo** built with TypeScript, structured as: + +``` +Roo-Code/ +├── src/ ← VSCode Extension Host (the main agent logic) +│ ├── extension.ts ← Entry point: activates the extension +│ ├── core/ +│ │ ├── task/Task.ts ← THE agent brain. Manages the entire conversation loop. +│ │ ├── tools/ ← Every tool the agent can call (read, write, execute...) +│ │ ├── prompts/system.ts ← Builds the system prompt sent to the LLM +│ │ ├── assistant-message/ ← Processes what the LLM returns (tool calls, text) +│ │ └── webview/ ← Bridge to the UI panel +│ └── services/ ← MCP, checkpoints, skills +├── packages/ +│ └── types/src/tool.ts ← Canonical list of all tool names (ToolName type) +└── apps/ ← Web app, CLI +``` + +--- + +## 2. How the Agent Loop Works (The Nervous System) + +The agent is a **request-response loop** between the LLM and the IDE. Here is the complete flow: + +``` +User types a message + ↓ +Task.ts → getSystemPrompt() → SYSTEM_PROMPT() in src/core/prompts/system.ts + ↓ +Task.ts → makeApiRequest() → sends [systemPrompt + conversation history] to Claude/OpenAI + ↓ +LLM responds with content blocks: + - "text" block → displayed to user + - "tool_use" block → intercepted for execution + ↓ +presentAssistantMessage() in src/core/assistant-message/presentAssistantMessage.ts + ↓ +switch (block.name) { + case "write_to_file" → WriteToFileTool.execute() + case "execute_command" → ExecuteCommandTool.execute() + case "read_file" → ReadFileTool.execute() + ...each tool handles its own askApproval + result +} + ↓ +Tool result pushed back → next LLM turn +``` + +--- + +## 3. The Three Critical Files (Hook Insertion Points) + +### 3.1 Tool Dispatch — `src/core/assistant-message/presentAssistantMessage.ts` + +**Line 678 — The switch(block.name) block** + +This is the single most important location in the entire codebase. Every tool call from the LLM passes through this switch statement. There is **no other path**. This is where: + +- **Pre-Hooks go**: BEFORE the switch executes (before any tool runs) +- **Post-Hooks go**: AFTER the tool case completes (after the file is written / command is run) + +```typescript +// LINE 678 in presentAssistantMessage.ts +switch (block.name) { + case "write_to_file": ← mutating: needs Pre-Hook + Post-Hook + await writeToFileTool.handle(...) + break + case "execute_command": ← destructive: needs Pre-Hook (HITL approval) + await executeCommandTool.handle(...) + break + case "read_file": ← safe: no hook needed + ... +} +``` + +### 3.2 System Prompt — `src/core/task/Task.ts` line 3792 → `src/core/prompts/system.ts` + +The system prompt is built by `getSystemPrompt()` (private method on Task, line 3745), which calls `SYSTEM_PROMPT()` in `system.ts`. This function assembles modular sections from `src/core/prompts/sections/`. + +**This is where we inject the intent enforcement instruction:** + +> "You CANNOT write code immediately. Your FIRST action MUST be `select_active_intent`." + +### 3.3 Tool Definitions — `packages/types/src/tool.ts` + +The array `toolNames` (line 24) is the canonical registry of all valid tool names. Adding `"select_active_intent"` here makes it a first-class tool recognized by the parser and type system. + +--- + +## 4. The Hook Architecture We Are Building + +### 4.1 The Two-Stage State Machine + +``` +User: "Refactor the auth middleware" + │ + ▼ + ┌─────────────────────────┐ + │ LLM analyzes request │ + │ (State 1: The Request) │ + └───────────┬─────────────┘ + │ LLM calls: select_active_intent("INT-001") + ▼ + ┌─────────────────────────────────────────────────┐ + │ PRE-HOOK fires on select_active_intent │ + │ → Reads .orchestration/active_intents.yaml │ + │ → Finds INT-001: constraints + owned_scope │ + │ → Returns XML block to LLM │ + │ (State 2: The Handshake) │ + └───────────┬─────────────────────────────────────┘ + │ LLM now has context, calls: write_to_file("src/auth/middleware.ts", ...) + ▼ + ┌─────────────────────────────────────────────────┐ + │ PRE-HOOK fires on write_to_file │ + │ → Checks: active intent declared? ✓ │ + │ → Checks: src/auth/middleware.ts in scope? ✓ │ + │ → Allows execution to proceed │ + └───────────┬─────────────────────────────────────┘ + │ WriteToFileTool.execute() runs — file is saved + ▼ + ┌─────────────────────────────────────────────────┐ + │ POST-HOOK fires after write_to_file │ + │ → Computes SHA-256 of written content │ + │ → Appends JSON record to agent_trace.jsonl │ + │ → Links: INT-001 → src/auth/middleware.ts │ + │ (State 3: Contextualized Action + Trace) │ + └─────────────────────────────────────────────────┘ +``` + +### 4.2 What Gets Blocked + +``` +Agent tries write_to_file WITHOUT calling select_active_intent first: + → PRE-HOOK: IntentGate fires → BLOCKED + → Returns: "Error: You must call select_active_intent before writing files." + +Agent tries to write src/billing/invoice.ts but INT-001 only owns src/auth/**: + → PRE-HOOK: ScopeGuard fires → BLOCKED + → Returns: "Scope Violation: INT-001 is not authorized to edit src/billing/invoice.ts" +``` + +--- + +## 5. The src/hooks/ Directory Structure + +``` +src/hooks/ +├── types.ts ← Shared types: HookContext, HookResult, IntentState +├── HookEngine.ts ← The singleton middleware engine +│ Manages per-task intent state +│ Runs pre/post hook chains +├── preHooks/ +│ ├── intentGate.ts ← Blocks mutating tools if no intent is declared +│ └── scopeGuard.ts ← Blocks writes outside the intent's owned_scope +├── postHooks/ +│ └── traceLedger.ts ← SHA-256 hash + append to agent_trace.jsonl +└── utils/ + ├── contentHash.ts ← SHA-256 helper (crypto built-in) + ├── intentLoader.ts ← Parses .orchestration/active_intents.yaml + └── orchestrationPaths.ts ← Centralized .orchestration/ path resolution +``` + +--- + +## 6. The Data Model (.orchestration/) + +``` +.orchestration/ +├── active_intents.yaml ← What work is authorized (the "why") +├── agent_trace.jsonl ← Append-only ledger of every action (the "proof") +└── intent_map.md ← Which files belong to which intent (the "map") +``` + +### active_intents.yaml schema: + +```yaml +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" + acceptance_criteria: + - "Unit tests in tests/auth/ pass" +``` + +### agent_trace.jsonl record schema (spatial independence via content hash): + +```json +{ + "id": "uuid-v4", + "timestamp": "ISO-8601", + "intent_id": "INT-001", + "vcs": { "revision_id": "git_sha" }, + "files": [ + { + "relative_path": "src/auth/middleware.ts", + "contributor": { "entity_type": "AI", "model_identifier": "claude-3-5-sonnet" }, + "ranges": [ + { + "start_line": 1, + "end_line": 45, + "content_hash": "sha256:a8f5f167..." + } + ], + "mutation_class": "AST_REFACTOR", + "related": [{ "type": "specification", "value": "INT-001" }] + } + ] +} +``` + +--- + +## 7. The select_active_intent Tool + +A new first-class tool added to the agent's toolset. The LLM MUST call this before any mutating action. + +**Input:** `{ intent_id: string }` + +**What happens when called:** + +1. HookEngine reads `active_intents.yaml` and finds the intent +2. Extracts constraints, owned_scope, acceptance_criteria +3. Returns an `` XML block back to the LLM +4. Marks the intent as active in per-task state (Map) + +**What the LLM receives:** + +```xml + + + + src/auth/** + src/middleware/jwt.ts + + + Must not use external auth providers + + + Unit tests in tests/auth/ pass + + + +``` + +--- + +## 8. System Prompt Modification + +The following instruction is injected into the system prompt (in `src/core/prompts/system.ts`): + +``` +# Intent-Driven Governance Protocol + +You are operating under a strict governance system. You CANNOT write, edit, or delete +files immediately. Your FIRST action for any code modification task MUST be: + +1. Analyze the user's request +2. Call `select_active_intent(intent_id)` with the appropriate intent ID from + .orchestration/active_intents.yaml +3. Wait for the block to be returned +4. Only THEN proceed with code modifications — and only within the declared scope + +If you attempt to call write_to_file, apply_diff, edit, or execute_command +without first calling select_active_intent, the system will BLOCK your action +and return an error. +``` + +--- + +## 9. Key Architectural Decisions + +| Decision | Choice | Reason | +| --------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | +| Hook insertion point | `presentAssistantMessage.ts` before switch(block.name) | Single choke point — ALL tools pass through here | +| Intent state storage | `Map` in HookEngine singleton | No Task.ts modification needed; isolated | +| Content hashing | Node.js `crypto.createHash('sha256')` | Zero dependency, always available in Extension Host | +| YAML parsing | `yaml` package (already in src/package.json) | Already a project dependency | +| Scope matching | Simple prefix/glob matching | Sufficient for the demo; expandable to minimatch | +| Trace format | Append-only JSONL | Machine-readable, spatially independent, append-safe | +| select_active_intent registration | Added to `toolNames` in `packages/types/src/tool.ts` | Cleanest: makes it first-class, recognized by parser | + +--- + +## 10. Files Modified / Created + +### Modified: + +- `packages/types/src/tool.ts` — Added `"select_active_intent"` to toolNames +- `src/core/assistant-message/presentAssistantMessage.ts` — Wired pre/post hooks + select_active_intent case +- `src/core/prompts/system.ts` — Injected intent enforcement instruction + +### Created: + +- `src/hooks/` — Entire hooks directory (new) +- `src/core/tools/SelectActiveIntentTool.ts` — The new tool +- `.orchestration/active_intents.yaml` — Sample intent definitions +- `.orchestration/agent_trace.jsonl` — Empty ledger (machine-managed) +- `.orchestration/intent_map.md` — Intent-to-file spatial map diff --git a/package.json b/package.json index de8dff751c..2070f8e675 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,17 @@ "@types/react": "^18.3.23", "@types/react-dom": "^18.3.5", "zod": "3.25.76" - } + }, + "ignoredBuiltDependencies": [ + "@tailwindcss/oxide", + "@vscode/vsce-sign", + "better-sqlite3", + "core-js", + "esbuild", + "keytar", + "protobufjs", + "puppeteer-chromium-resolver", + "sharp" + ] } } diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..3ff7bf4915 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,8 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + // Intent-Driven Governance: mandatory intent declaration tool + "select_active_intent", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..1f257acce8 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,10 +37,14 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { selectActiveIntentTool } from "../tools/SelectActiveIntentTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +// Intent-Driven Governance: Hook Engine middleware +import { hookEngine } from "../../hooks/HookEngine" + /** * Processes and presents assistant message content to the user interface. * @@ -675,7 +679,32 @@ export async function presentAssistantMessage(cline: Task) { } } + // ── Intent-Driven Governance: Pre-Hook ───────────────────────────────── + // Runs before every tool. Blocks mutating tools if no intent is declared, + // or if the target file is outside the active intent's owned_scope. + // select_active_intent itself is exempt from the gate check. + if (block.name !== "select_active_intent" && !block.partial) { + const preHookResult = await hookEngine.runPreHook( + block.name, + (block.nativeArgs ?? {}) as Record, + cline.taskId, + cline.cwd, + ) + if (preHookResult.blocked) { + pushToolResult(formatResponse.toolError(preHookResult.reason ?? "Blocked by governance hook.")) + break + } + } + // ── End Pre-Hook ──────────────────────────────────────────────────────── + switch (block.name) { + case "select_active_intent": + await selectActiveIntentTool.handle(cline, block as ToolUse<"select_active_intent">, { + askApproval, + handleError, + pushToolResult, + }) + break case "write_to_file": await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { @@ -683,6 +712,18 @@ export async function presentAssistantMessage(cline: Task) { handleError, pushToolResult, }) + // ── Post-Hook: Trace Ledger ────────────────────────────────────────── + if (!block.partial) { + hookEngine + .runPostHook( + "write_to_file", + (block.nativeArgs ?? {}) as Record, + cline.taskId, + cline.cwd, + cline.api.getModel().id, + ) + .catch((err) => console.error("[PostHook] traceLedger error:", err)) + } break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a..15f9619909 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -82,6 +82,35 @@ async function generatePrompt( // Tools catalog is not included in the system prompt. const toolsCatalog = "" + // ── Intent-Driven Governance Protocol ──────────────────────────────────── + const intentEnforcementSection = ` +# Intent-Driven Governance Protocol + +You are operating under a strict **Intent-Driven Governance System**. The following rules are MANDATORY and enforced by the system — violations will be BLOCKED automatically. + +## The Handshake Rule (Non-Negotiable) + +You **CANNOT** write, edit, delete, or execute files immediately after receiving a user request. + +Your **FIRST action** for any code modification task MUST follow this exact sequence: + +1. **Analyze** the user's request and identify the relevant intent ID from \`.orchestration/active_intents.yaml\` +2. **Call** \`select_active_intent({ intent_id: "INT-XXX" })\` to load your authorized context +3. **Wait** for the \`\` block to be returned — it contains your constraints and scope +4. **Only then** proceed with code modifications — and ONLY within the declared \`owned_scope\` + +## What Gets Blocked + +- Calling \`write_to_file\`, \`apply_diff\`, \`edit\`, or \`execute_command\` WITHOUT first calling \`select_active_intent\` → **BLOCKED** +- Writing a file that is OUTSIDE your active intent's \`owned_scope\` → **BLOCKED** + +## Why This Exists + +Every change you make is cryptographically traced and linked to a business intent. This creates an auditable chain: **Business Intent → Your Action → Code Hash**. This is how trust is built without blind acceptance. + +If no active_intents.yaml exists yet, read \`.orchestration/active_intents.yaml\` first to understand what intents are defined, then call \`select_active_intent\` with the appropriate ID.` + // ── End Intent-Driven Governance Protocol ──────────────────────────────── + const basePrompt = `${roleDefinition} ${markdownFormattingSection()} @@ -99,6 +128,7 @@ ${getRulesSection(cwd, settings)} ${getSystemInfoSection(cwd)} ${getObjectiveSection()} +${intentEnforcementSection} ${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), diff --git a/src/core/tools/SelectActiveIntentTool.ts b/src/core/tools/SelectActiveIntentTool.ts new file mode 100644 index 0000000000..0f32347dfb --- /dev/null +++ b/src/core/tools/SelectActiveIntentTool.ts @@ -0,0 +1,119 @@ +/** + * SelectActiveIntentTool — The Mandatory Handshake + * + * This tool is the FIRST thing the agent must call before modifying any file. + * It implements the "Two-Stage State Machine" from the architecture spec: + * + * Stage 1 (Request): User asks for a code change + * Stage 2 (Handshake): Agent calls select_active_intent → gets context injected + * Stage 3 (Action): Agent now writes code with full intent context + * + * What this tool does: + * 1. Reads .orchestration/active_intents.yaml + * 2. Finds the intent by ID + * 3. Registers it as the active intent in the HookEngine (per-task state) + * 4. Returns an XML block back to the LLM + * — the LLM now knows: what files it can touch, what constraints apply, + * and what "done" looks like + */ + +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import { findIntentById } from "../../hooks/utils/intentLoader" +import { hookEngine } from "../../hooks/HookEngine" +import { IntentState } from "../../hooks/types" +import type { ToolUse } from "../../shared/tools" + +interface SelectActiveIntentParams { + intent_id: string +} + +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { + readonly name = "select_active_intent" as const + + async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + const { intent_id } = params + + if (!intent_id) { + pushToolResult( + `[select_active_intent] Error: 'intent_id' parameter is required.\n` + + `Please provide a valid intent ID from .orchestration/active_intents.yaml`, + ) + return + } + + // Load the intent from the YAML file + const intent = await findIntentById(task.cwd, intent_id) + + if (!intent) { + pushToolResult( + `[select_active_intent] Error: Intent '${intent_id}' not found in .orchestration/active_intents.yaml\n` + + `Available intent IDs can be found by reading: .orchestration/active_intents.yaml`, + ) + return + } + + if (intent.status === "COMPLETED" || intent.status === "CANCELLED") { + pushToolResult( + `[select_active_intent] Error: Intent '${intent_id}' has status '${intent.status}' and cannot be activated.\n` + + `Only IN_PROGRESS or PENDING intents can be selected.`, + ) + return + } + + // Register the active intent in the HookEngine (per-task state) + const intentState: IntentState = { + intentId: intent.id, + intentName: intent.name, + ownedScope: intent.owned_scope ?? [], + constraints: intent.constraints ?? [], + acceptanceCriteria: intent.acceptance_criteria ?? [], + activatedAt: new Date().toISOString(), + } + hookEngine.setActiveIntent(task.taskId, intentState) + + // Build the XML block for the LLM + // This is what gets injected into the model's context + const scopeXml = + intentState.ownedScope.length > 0 + ? `\n${intentState.ownedScope.map((p) => ` ${p}`).join("\n")}\n ` + : `` + + const constraintsXml = + intentState.constraints.length > 0 + ? `\n${intentState.constraints.map((c) => ` ${c}`).join("\n")}\n ` + : "" + + const criteriaXml = + intentState.acceptanceCriteria.length > 0 + ? `\n${intentState.acceptanceCriteria.map((c) => ` ${c}`).join("\n")}\n ` + : "" + + const intentContext = ` + + + ${scopeXml} + ${constraintsXml} + ${criteriaXml} + + You may ONLY modify files within the owned_scope paths listed above. + Any attempt to write outside this scope will be BLOCKED by the system. + All your changes will be traced and linked to intent ID: ${intent.id} + + + + +Intent '${intent.id}' is now active. You have loaded the context for: "${intent.name}". +You may now proceed with code modifications within the declared scope. +`.trim() + + pushToolResult(intentContext) + } + + override async handlePartial(task: Task, block: ToolUse<"select_active_intent">): Promise { + // No streaming UI needed for this tool + } +} + +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts new file mode 100644 index 0000000000..21ca2ad555 --- /dev/null +++ b/src/hooks/HookEngine.ts @@ -0,0 +1,163 @@ +/** + * Hook Engine — The Governance Middleware + * + * This is the central middleware layer that intercepts ALL tool executions. + * It is a singleton: one instance exists per extension activation. + * + * Architecture: + * Pre-Hooks → run BEFORE a tool executes → can BLOCK execution + * Post-Hooks → run AFTER a tool executes → record trace, update state + * + * Per-task state (which intent is active) is tracked in a Map. + * The Task object is NOT modified — state lives entirely in this engine. + * + * Insertion point in Roo Code: + * src/core/assistant-message/presentAssistantMessage.ts + * → before switch(block.name) [Pre-Hook] + * → after write_to_file completes [Post-Hook] + */ + +import { HookContext, HookResult, IntentState } from "./types" +import { runIntentGate } from "./preHooks/intentGate" +import { runScopeGuard } from "./preHooks/scopeGuard" +import { appendTraceRecord } from "./postHooks/traceLedger" + +export class HookEngine { + private static _instance: HookEngine | null = null + + /** + * Per-task active intent state. + * Key: taskId Value: IntentState (what was declared via select_active_intent) + */ + private intentStateMap = new Map() + + private constructor() {} + + /** + * Singleton accessor — always use this, never `new HookEngine()`. + */ + static getInstance(): HookEngine { + if (!HookEngine._instance) { + HookEngine._instance = new HookEngine() + } + return HookEngine._instance + } + + // ─── Intent State Management ──────────────────────────────────────────────── + + /** + * Set the active intent for a task (called when select_active_intent is executed). + */ + setActiveIntent(taskId: string, state: IntentState): void { + this.intentStateMap.set(taskId, state) + } + + /** + * Get the active intent ID for a task, or null if none is set. + */ + getActiveIntentId(taskId: string): string | null { + return this.intentStateMap.get(taskId)?.intentId ?? null + } + + /** + * Get the full intent state for a task. + */ + getIntentState(taskId: string): IntentState | null { + return this.intentStateMap.get(taskId) ?? null + } + + /** + * Clear the active intent for a task (called on task completion/reset). + */ + clearIntent(taskId: string): void { + this.intentStateMap.delete(taskId) + } + + // ─── Pre-Hook Chain ────────────────────────────────────────────────────────── + + /** + * Run all pre-hooks for a tool call. + * Returns the first blocking result found, or { blocked: false } if all pass. + * + * Pre-hooks run in order: + * 1. IntentGate — Is there any intent declared? + * 2. ScopeGuard — Is the target file in scope? + */ + async runPreHook( + toolName: string, + toolParams: Record, + taskId: string, + cwd: string, + ): Promise { + const ctx: HookContext = { + taskId, + cwd, + toolName, + toolParams, + activeIntentId: this.getActiveIntentId(taskId), + } + + // 1. Intent Gate: no intent = no mutating actions + const gateResult = await runIntentGate(ctx) + if (gateResult.blocked) { + return gateResult + } + + // 2. Scope Guard: file must be within declared scope + const scopeResult = await runScopeGuard(ctx) + if (scopeResult.blocked) { + return scopeResult + } + + return { blocked: false } + } + + // ─── Post-Hook Chain ───────────────────────────────────────────────────────── + + /** + * Run all post-hooks after a tool completes. + * Currently: append a trace record for file-writing tools. + * Post-hooks NEVER block — they record and move on. + */ + async runPostHook( + toolName: string, + toolParams: Record, + taskId: string, + cwd: string, + modelId: string, + ): Promise { + // Only trace file-writing operations + const fileWriteTools = new Set([ + "write_to_file", + "apply_diff", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + ]) + + if (!fileWriteTools.has(toolName)) { + return + } + + const filePath = (toolParams.path as string | undefined) ?? "" + const content = (toolParams.content as string | undefined) ?? "" + + if (!filePath) { + return + } + + await appendTraceRecord({ + taskId, + cwd, + intentId: this.getActiveIntentId(taskId), + filePath, + content, + modelId, + }) + } +} + +// Export the singleton for use across the codebase +export const hookEngine = HookEngine.getInstance() diff --git a/src/hooks/postHooks/traceLedger.ts b/src/hooks/postHooks/traceLedger.ts new file mode 100644 index 0000000000..09f254b856 --- /dev/null +++ b/src/hooks/postHooks/traceLedger.ts @@ -0,0 +1,115 @@ +/** + * Post-Hook: Trace Ledger + * + * After every file write, this hook: + * 1. Computes a SHA-256 hash of the written content (spatial independence) + * 2. Gets the current git commit SHA for VCS linkage + * 3. Classifies the change (AST_REFACTOR vs INTENT_EVOLUTION) + * 4. Appends a JSON record to .orchestration/agent_trace.jsonl + * + * This is the cryptographic proof that links: + * Business Intent → AI Action → Code Hash + */ + +import fs from "fs/promises" +import { execSync } from "child_process" +import { v4 as uuidv4 } from "uuid" +import { MutationClass, TraceRecord } from "../types" +import { computeContentHash, countLines } from "../utils/contentHash" +import { getTraceLedgerPath, getOrchestrationDir } from "../utils/orchestrationPaths" + +interface TraceLedgerContext { + taskId: string + cwd: string + intentId: string | null + filePath: string // relative path of the written file + content: string // the content that was written + modelId: string // e.g. "claude-3-5-sonnet" + mutationClass?: MutationClass +} + +/** + * Get the current git revision SHA (short). + * Returns "unknown" if git is not available. + */ +function getGitRevision(cwd: string): string { + try { + return execSync("git rev-parse --short HEAD", { cwd, stdio: ["pipe", "pipe", "pipe"] }) + .toString() + .trim() + } catch { + return "unknown" + } +} + +/** + * Classify the mutation type based on simple heuristics. + * A real implementation would use AST diffing. + * + * - INTENT_EVOLUTION: file is new (didn't exist before) → new feature + * - AST_REFACTOR: file existed → structural change preserving intent + */ +function classifyMutation(filePath: string, isNewFile: boolean): MutationClass { + if (isNewFile) { + return "INTENT_EVOLUTION" + } + return "AST_REFACTOR" +} + +/** + * Append a trace record to agent_trace.jsonl. + * Each line is a self-contained JSON object (JSONL format). + */ +export async function appendTraceRecord(ctx: TraceLedgerContext): Promise { + try { + // Ensure .orchestration/ directory exists + const orchestrationDir = getOrchestrationDir(ctx.cwd) + await fs.mkdir(orchestrationDir, { recursive: true }) + + const ledgerPath = getTraceLedgerPath(ctx.cwd) + + // Determine if this is a new file (for mutation classification) + let isNewFile = false + try { + await fs.access(`${ctx.cwd}/${ctx.filePath}`) + } catch { + isNewFile = true + } + + const contentHash = computeContentHash(ctx.content) + const lineCount = countLines(ctx.content) + const mutationClass = ctx.mutationClass ?? classifyMutation(ctx.filePath, isNewFile) + const gitRevision = getGitRevision(ctx.cwd) + + const record: TraceRecord = { + id: uuidv4(), + timestamp: new Date().toISOString(), + intent_id: ctx.intentId, + vcs: { revision_id: gitRevision }, + files: [ + { + relative_path: ctx.filePath, + contributor: { + entity_type: "AI", + model_identifier: ctx.modelId, + }, + ranges: [ + { + start_line: 1, + end_line: lineCount, + content_hash: contentHash, + }, + ], + mutation_class: mutationClass, + related: ctx.intentId ? [{ type: "specification", value: ctx.intentId }] : [], + }, + ], + } + + // Append one JSON line (JSONL = one record per line, append-only) + await fs.appendFile(ledgerPath, JSON.stringify(record) + "\n", "utf-8") + } catch (error) { + // Trace failure must NOT crash the agent — log and continue + console.error("[TraceLedger] Failed to append trace record:", error) + } +} diff --git a/src/hooks/preHooks/intentGate.ts b/src/hooks/preHooks/intentGate.ts new file mode 100644 index 0000000000..34ee6c45d2 --- /dev/null +++ b/src/hooks/preHooks/intentGate.ts @@ -0,0 +1,37 @@ +/** + * Pre-Hook: Intent Gate + * + * The fundamental governance rule: an agent CANNOT mutate the codebase + * without first declaring a valid active intent via select_active_intent(). + * + * If the agent tries to write a file or run a command without having + * declared an intent, this hook BLOCKS the action and returns an error + * that the LLM can understand and self-correct from. + */ + +import { HookContext, HookResult, MUTATING_TOOLS } from "../types" + +/** + * Run the intent gate check. + * Returns { blocked: true } if a mutating tool is called without an active intent. + */ +export async function runIntentGate(ctx: HookContext): Promise { + // Only enforce on mutating tools + if (!MUTATING_TOOLS.has(ctx.toolName as any)) { + return { blocked: false } + } + + // If there is no active intent, block and explain clearly + if (!ctx.activeIntentId) { + return { + blocked: true, + reason: + `[Intent Gate] BLOCKED: You attempted to call '${ctx.toolName}' without a declared intent.\n` + + `You MUST first call 'select_active_intent' with a valid intent ID from ` + + `.orchestration/active_intents.yaml before modifying any files.\n` + + `Example: select_active_intent({ intent_id: "INT-001" })`, + } + } + + return { blocked: false } +} diff --git a/src/hooks/preHooks/scopeGuard.ts b/src/hooks/preHooks/scopeGuard.ts new file mode 100644 index 0000000000..5c4136895a --- /dev/null +++ b/src/hooks/preHooks/scopeGuard.ts @@ -0,0 +1,80 @@ +/** + * Pre-Hook: Scope Guard + * + * Enforces the owned_scope declared in active_intents.yaml. + * Even if an intent is declared, the agent can only modify files + * that are explicitly within that intent's scope. + * + * This prevents agents from "drifting" into unrelated code while + * claiming to work on a specific intent. + */ + +import { HookContext, HookResult } from "../types" +import { findIntentById, isPathInScope } from "../utils/intentLoader" + +// Tools that write to a specific file path (we check their 'path' param) +const FILE_WRITE_TOOLS = new Set([ + "write_to_file", + "apply_diff", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", +]) + +/** + * Run the scope guard check. + * Returns { blocked: true } if the target file is outside the active intent's scope. + */ +export async function runScopeGuard(ctx: HookContext): Promise { + // Only enforce on file-writing tools + if (!FILE_WRITE_TOOLS.has(ctx.toolName)) { + return { blocked: false } + } + + // No active intent means intentGate already blocked this — skip + if (!ctx.activeIntentId) { + return { blocked: false } + } + + // Extract the target file path from tool params + const targetPath = (ctx.toolParams.path as string | undefined) ?? "" + if (!targetPath) { + return { blocked: false } + } + + // Load the active intent to get its scope + const intent = await findIntentById(ctx.cwd, ctx.activeIntentId) + if (!intent) { + return { + blocked: true, + reason: + `[Scope Guard] BLOCKED: Active intent '${ctx.activeIntentId}' not found in ` + + `.orchestration/active_intents.yaml. The intent may have been removed or renamed. ` + + `Call select_active_intent again with a valid ID.`, + } + } + + // If scope is undefined or empty, allow (no restriction defined) + if (!intent.owned_scope || intent.owned_scope.length === 0) { + return { blocked: false } + } + + // Check if the target file is within the declared scope + if (!isPathInScope(targetPath, intent.owned_scope)) { + return { + blocked: true, + reason: + `[Scope Guard] BLOCKED: Scope Violation.\n` + + `Intent '${ctx.activeIntentId}' (${intent.name}) is NOT authorized to edit: ${targetPath}\n` + + `Authorized scope:\n` + + intent.owned_scope.map((s) => ` - ${s}`).join("\n") + + `\nTo modify this file, either:\n` + + ` 1. Switch to a different intent that owns this file, or\n` + + ` 2. Request a scope expansion for intent ${ctx.activeIntentId}.`, + } + } + + return { blocked: false } +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000000..ead503ce80 --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,106 @@ +/** + * Hook Engine Types + * Shared types for the Intent-Driven Governance Hook System. + */ + +// The set of tool names that mutate the codebase and REQUIRE an active intent. +export const MUTATING_TOOLS = new Set([ + "write_to_file", + "apply_diff", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + "execute_command", +] as const) + +// The set of tools that are purely destructive (need extra HITL warning). +export const DESTRUCTIVE_TOOLS = new Set(["execute_command"] as const) + +// Tools that set intent (exempt from the intent gate check). +export const INTENT_TOOLS = new Set(["select_active_intent"] as const) + +/** + * The context passed to every hook. + * Contains everything the hook needs to make a decision. + */ +export interface HookContext { + taskId: string + cwd: string // workspace root path + toolName: string + toolParams: Record + activeIntentId: string | null // currently declared intent for this task +} + +/** + * The result a hook returns. + * If blocked=true, execution is stopped and reason is returned to the LLM. + */ +export interface HookResult { + blocked: boolean + reason?: string +} + +/** + * Per-task intent state tracked by the HookEngine. + */ +export interface IntentState { + intentId: string + intentName: string + ownedScope: string[] + constraints: string[] + acceptanceCriteria: string[] + activatedAt: string // ISO timestamp +} + +/** + * A single intent as parsed from active_intents.yaml. + */ +export interface ActiveIntent { + id: string + name: string + status: string + owned_scope: string[] + constraints?: string[] + acceptance_criteria?: string[] +} + +/** + * The full structure of active_intents.yaml. + */ +export interface ActiveIntentsFile { + active_intents: ActiveIntent[] +} + +/** + * Classification of a mutation for the trace ledger. + */ +export type MutationClass = "AST_REFACTOR" | "INTENT_EVOLUTION" | "BUG_FIX" | "UNKNOWN" + +/** + * A single record appended to agent_trace.jsonl. + */ +export interface TraceRecord { + id: string + timestamp: string + intent_id: string | null + vcs: { revision_id: string } + files: Array<{ + relative_path: string + contributor: { + entity_type: "AI" | "HUMAN" + model_identifier: string + } + ranges: Array<{ + start_line: number + end_line: number + content_hash: string + }> + mutation_class: MutationClass + related: Array<{ + type: string + value: string + }> + }> +} diff --git a/src/hooks/utils/contentHash.ts b/src/hooks/utils/contentHash.ts new file mode 100644 index 0000000000..bcbc7fe510 --- /dev/null +++ b/src/hooks/utils/contentHash.ts @@ -0,0 +1,25 @@ +/** + * Content hashing utility for spatial independence. + * + * The key insight: line numbers shift when code is refactored. + * A SHA-256 hash of the actual content does NOT change with line shifts. + * This means we can always find and verify a code block even after refactoring. + */ + +import crypto from "crypto" + +/** + * Compute a SHA-256 hash of a string content block. + * Returns the hash as "sha256:" for clear identification. + */ +export function computeContentHash(content: string): string { + const hash = crypto.createHash("sha256").update(content, "utf8").digest("hex") + return `sha256:${hash}` +} + +/** + * Count lines in a string (1-based end line number). + */ +export function countLines(content: string): number { + return content.split("\n").length +} diff --git a/src/hooks/utils/intentLoader.ts b/src/hooks/utils/intentLoader.ts new file mode 100644 index 0000000000..27045e5181 --- /dev/null +++ b/src/hooks/utils/intentLoader.ts @@ -0,0 +1,70 @@ +/** + * Reads and parses .orchestration/active_intents.yaml. + * + * This is the single source of truth for what work is authorized. + * Every pre-hook reads from here; it is never written by hooks directly + * (humans maintain it, or a future tool updates it). + */ + +import fs from "fs/promises" +import { parse as parseYaml } from "yaml" +import { ActiveIntent, ActiveIntentsFile } from "../types" +import { getActiveIntentsPath } from "./orchestrationPaths" + +/** + * Load all active intents from the workspace's active_intents.yaml. + * Returns an empty array if the file does not exist (graceful degradation). + */ +export async function loadActiveIntents(cwd: string): Promise { + const filePath = getActiveIntentsPath(cwd) + try { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = parseYaml(raw) as ActiveIntentsFile + return parsed?.active_intents ?? [] + } catch { + // File doesn't exist or is invalid YAML — governance cannot be enforced + return [] + } +} + +/** + * Find a specific intent by its ID. + * Returns null if not found or file doesn't exist. + */ +export async function findIntentById(cwd: string, intentId: string): Promise { + const intents = await loadActiveIntents(cwd) + return intents.find((i) => i.id === intentId) ?? null +} + +/** + * Check whether a file path falls within any of the intent's owned_scope patterns. + * + * Scope matching rules: + * - "src/auth/**" matches any file under src/auth/ + * - "src/middleware/jwt.ts" matches exactly that file + * - Paths are compared using normalized forward slashes + */ +export function isPathInScope(filePath: string, ownedScope: string[]): boolean { + // Normalize to forward slashes for cross-platform consistency + const normalized = filePath.replace(/\\/g, "/") + + return ownedScope.some((pattern) => { + const normalizedPattern = pattern.replace(/\\/g, "/") + + // Glob-style: pattern ends with /** — match any file under the prefix + if (normalizedPattern.endsWith("/**")) { + const prefix = normalizedPattern.slice(0, -3) + return normalized === prefix || normalized.startsWith(prefix + "/") + } + + // Glob-style: pattern ends with /* — match any direct child + if (normalizedPattern.endsWith("/*")) { + const prefix = normalizedPattern.slice(0, -2) + const rest = normalized.slice(prefix.length + 1) + return normalized.startsWith(prefix + "/") && !rest.includes("/") + } + + // Exact match + return normalized === normalizedPattern + }) +} diff --git a/src/hooks/utils/orchestrationPaths.ts b/src/hooks/utils/orchestrationPaths.ts new file mode 100644 index 0000000000..100c86a314 --- /dev/null +++ b/src/hooks/utils/orchestrationPaths.ts @@ -0,0 +1,24 @@ +/** + * Centralizes all .orchestration/ path resolution. + * Every hook reads/writes through these helpers — no magic strings elsewhere. + */ + +import path from "path" + +export const ORCHESTRATION_DIR = ".orchestration" + +export function getOrchestrationDir(cwd: string): string { + return path.join(cwd, ORCHESTRATION_DIR) +} + +export function getActiveIntentsPath(cwd: string): string { + return path.join(cwd, ORCHESTRATION_DIR, "active_intents.yaml") +} + +export function getTraceLedgerPath(cwd: string): string { + return path.join(cwd, ORCHESTRATION_DIR, "agent_trace.jsonl") +} + +export function getIntentMapPath(cwd: string): string { + return path.join(cwd, ORCHESTRATION_DIR, "intent_map.md") +}