diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..f6fa9a52dee --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,19 @@ +AI Fluency Trigger Rules and Response Handling +You have access to 2 trigger logging tools for AI fluency tracking. Call these tools when specific patterns are detected and at the end of you work and wait for thier response: + +MUST: Wait for each trigger log and process the provided output in a proffesional matter. You must always show the response at the end of your answer + +Important Rules: +Use the analysis response to provide feedback to the user + +Make sure you provide the user with the repsonse from the triggers - triggers except log_passage_time_trigger respond with some sort of feedback and improvment mechanisms +The analysis should have this ********************\********************* at the start and end of feedback view + +Focus on the user's benefit - these tools help you provide better assistance and feedback + +Wait for performance trigger log and process the provided output in a proffesional matter +celebrate success, motivate improvement, and encouragment but display that in a block type then you can continue your work + +The analysis should have this ********************\********************* at the start and end of feedback view. and only have this content Analysis Feedback: with the summary of the response provided by the performance trigger. do not forget to show statistics of the analysis too. + +You must always show the response at the end of your answer, Do not show response from log_passage_time_trigger diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..8a86d772490 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,14 @@ +active_intents: + - id: "auth-refactor" + name: "Refactor auth flow" + status: "IN_PROGRESS" + owned_scope: + - "src/hooks/**" + - "src/integrations/openai-codex/**" + constraints: + - "Keep public API unchanged" + acceptance_criteria: + - "No regressions in auth flow" + related_files: + - "src/hooks/HookEngine.ts" + recent_history: [] diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 00000000000..6b7f423db52 --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,3 @@ +# Intent Map + +Machine-managed mapping between intent IDs and touched code locations. diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000000..dc2e4501ae8 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + "tenxfeedbackanalytics": { + "url": "https://mcppulse.10academy.org/proxy", + "type": "http", + "headers": { + "X-Device": "mac", + "X-Coding-Tool": "vscode" + } + } + }, + "inputs": [] +} diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000000..8d39da3f75b --- /dev/null +++ b/AGENT.md @@ -0,0 +1,5 @@ +# AGENT + +Machine-managed shared memory for architectural decisions and recurring failures. + +- 2026-02-18T10:36:06Z: Initialized shared brain baseline for hook governor rollout. diff --git a/ARCHITECTURE_NOTES.md b/ARCHITECTURE_NOTES.md new file mode 100644 index 00000000000..324e8d24a7d --- /dev/null +++ b/ARCHITECTURE_NOTES.md @@ -0,0 +1,523 @@ +# Phase 0 - Archaeological Dig (Roo Code) + +Date: 2026-02-16 +Repository: `/Users/gersumasfaw/Roo-Code-10x` + +This document maps the extension "nervous system" for: + +- how the extension host starts, +- where task/tool execution loops run, +- where `execute_command` and `write_to_file` are handled, +- where the system prompt is built. + +## 0. Single-Page Agent Architecture (Phase 0) + +```mermaid +flowchart LR + U["User"] --> WV["Webview UI"] + WV --> WMH["webviewMessageHandler"] + WMH --> CP["ClineProvider.createTask(...)"] + CP --> TK["Task Runtime"] + TK --> SP["Task.getSystemPrompt() / SYSTEM_PROMPT(...)"] + TK --> TB["buildNativeToolsArrayWithRestrictions(...)"] + TK --> API["api.createMessage(...)"] + API --> NTP["NativeToolCallParser"] + NTP --> PAM["presentAssistantMessage(...)"] + PAM --> HE["HookEngine preToolUse / postToolUse"] + HE --> TOOLS["Tool Classes (write/exec/read/etc.)"] + TOOLS --> EXT["Workspace FS / Terminal / MCP"] + HE --> ORCH[".orchestration sidecar files"] +``` + +Runtime planes: + +- UI plane: Webview rendering and event emission only. +- Logic plane: Extension Host orchestration, tool policy, API/MCP wiring. +- Execution plane: `Task` loop, stream parsing, centralized tool dispatch. +- Persistence plane: task history + sidecar orchestration files. + +Key control points: + +1. Activation and provider bootstrap: + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:120` + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:195` +2. Webview message boundary: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:570` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/ClineProvider.ts:2914` +3. Task loop and API call: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2531` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4008` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4299` +4. Tool dispatch boundary: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:63` +5. Hook middleware boundary: + - `/Users/gersumasfaw/Roo-Code-10x/src/hooks/HookEngine.ts:73` +6. Prompt builder: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3765` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:112` + +### Hook Lifecycle (Focused) + +```mermaid +sequenceDiagram + participant Model + participant Parser as NativeToolCallParser + participant Dispatch as presentAssistantMessage + participant Hook as HookEngine + participant Tool as ToolClass + participant Sidecar as .orchestration/* + + Model->>Parser: stream tool call + Parser->>Dispatch: ToolUse block + Dispatch->>Hook: preToolUse(task, block) + Hook-->>Dispatch: allow/deny + context + + alt denied + Dispatch-->>Model: tool_result error + else allowed + Dispatch->>Tool: handle(...) + Tool-->>Dispatch: result / failure + Dispatch->>Hook: postToolUse(task, block, context, executionSucceeded) + Hook->>Sidecar: update active_intents, trace, intent_map, AGENT + end +``` + +### Hook System Diagrams And Schemas + +#### A. Middleware Boundary Diagram + +```mermaid +flowchart TB + UI["Webview UI (Restricted)"] -->|postMessage| HOST["Extension Host (Logic)"] + HOST --> DISPATCH["Tool Dispatch: presentAssistantMessage(...)"] + DISPATCH --> PRE["HookEngine.preToolUse(...)"] + PRE -->|allow| TOOL["Tool Implementation"] + PRE -->|deny| ERR["tool_result error"] + TOOL --> POST["HookEngine.postToolUse(...)"] + POST --> SIDECAR[".orchestration sidecar"] +``` + +#### B. Pre-Hook Decision Flow + +```mermaid +flowchart TD + S["Incoming ToolUse"] --> A{"Tool is mutating?"} + A -->|No| OK["Allow execution"] + A -->|Yes| B{"activeIntentId set?"} + B -->|No| DENY1["Deny: must call select_active_intent first"] + B -->|Yes| C{"Intent exists in active_intents.yaml?"} + C -->|No| DENY2["Deny: active intent missing"] + C -->|Yes| D{"Touched paths inside owned_scope?"} + D -->|No| DENY3["Deny + shared brain scope violation entry"] + D -->|Yes| OK +``` + +#### C. Two-Stage Turn State Machine + +```mermaid +stateDiagram-v2 + [*] --> Request + Request --> Handshake: "Model calls select_active_intent(intent_id)" + Handshake --> Action: "Pre-hook injects/validates intent context" + Action --> [*]: "Post-hook logs traces and evolution" + Action --> Handshake: "If intent changes, re-checkout" +``` + +#### D. Canonical `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" + - "Must maintain backward compatibility with Basic Auth" + acceptance_criteria: + - "Unit tests in tests/auth/ pass" + related_files: + - "src/auth/middleware.ts" + recent_history: + - "PRE_HOOK write_to_file" +``` + +Compatibility note: + +- Loader currently accepts both `active_intents` and legacy `intents`. +- Legacy fields (`intent_id`, `title`) are normalized to (`id`, `name`) internally. + +#### E. `agent_trace.jsonl` Record Schema (per line) + +```json +{ + "id": "uuid-v4", + "timestamp": "2026-02-16T12:00:00Z", + "vcs": { "revision_id": "git_sha_hash" }, + "files": [ + { + "relative_path": "src/auth/middleware.ts", + "conversations": [ + { + "url": "task_or_session_id", + "contributor": { + "entity_type": "AI", + "model_identifier": "model-id" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 45, + "content_hash": "sha256:..." + } + ], + "related": [ + { + "type": "specification", + "value": "INT-001" + } + ] + } + ] + } + ] +} +``` + +Implementation note: + +- Current implementation computes `content_hash` from entire touched file content (SHA-256) and records a single range per file. + +#### F. Hook Contract Schemas + +Pre-hook result (runtime contract): + +```json +{ + "allowExecution": true, + "errorMessage": "optional string", + "context": { + "toolName": "write_to_file", + "isMutatingTool": true, + "intentId": "INT-001", + "touchedPaths": ["src/auth/middleware.ts"], + "hadToolFailureBefore": false + } +} +``` + +Post-hook side effects: + +1. Append recent history entry to selected intent. +2. For mutating success: append `agent_trace.jsonl` and `intent_map.md`. +3. For failures/scope issues/completion evolution: append `AGENT.md`. + +## 1. Fork & Run Status + +This workspace already contains the Roo Code source and is buildable. + +Run commands: + +```sh +cd /Users/gersumasfaw/Roo-Code-10x +pnpm install +pnpm --filter roo-cline bundle +``` + +Observed result: + +- `bundle` succeeded and built `/Users/gersumasfaw/Roo-Code-10x/src/dist`. +- Warning only: local Node is `v24.9.0` while repo expects `20.19.2`. + +Extension Host run path: + +1. Open `/Users/gersumasfaw/Roo-Code-10x` in VS Code. +2. Press `F5` (`Run Extension` launch profile). + +Evidence: + +- Development instructions: `/Users/gersumasfaw/Roo-Code-10x/src/README.md:90` +- F5 instructions: `/Users/gersumasfaw/Roo-Code-10x/src/README.md:108` +- Extension host launch config: `/Users/gersumasfaw/Roo-Code-10x/.vscode/launch.json:1` +- Background watch tasks used by prelaunch: `/Users/gersumasfaw/Roo-Code-10x/.vscode/tasks.json:1` + +## 2. High-Level Runtime Path (Webview -> Task Loop -> Tools) + +1. Extension activation creates the main provider (`ClineProvider`). + + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:120` + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:195` + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:332` + +2. Webview sends `"newTask"` message; handler calls `provider.createTask(...)`. + + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:549` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:555` + +3. `ClineProvider.createTask(...)` constructs `new Task(...)`. + + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/ClineProvider.ts:2914` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/ClineProvider.ts:2974` + +4. `Task` constructor auto-starts when `startTask` is true. + + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:424` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:573` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:1924` + +5. Main agent loop: + + - `initiateTaskLoop(...)` calls `recursivelyMakeClineRequests(...)` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2477` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2511` + +6. API request and streaming: + + - `attemptApiRequest(...)` builds `systemPrompt`, builds tools, calls `api.createMessage(...)`. + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3988` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4020` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4238` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4279` + +7. Streamed tool calls are parsed and passed to `presentAssistantMessage(this)`. + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2865` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2918` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3015` + +## 3. Exact Tool Loop Handlers (Required by Phase 0) + +The central dispatch for tool execution is: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:61` + +Inside `case "tool_use"` it validates and dispatches tools. Key lines: + +- Tool-use switch start: `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:678` +- `write_to_file` dispatch: `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:679` +- `execute_command` dispatch: `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:764` + +### `execute_command` handling chain + +1. Dispatch call: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:765` +2. Tool implementation class: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/ExecuteCommandTool.ts:31` +3. Core execution logic: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/ExecuteCommandTool.ts:34` +4. Terminal execution function: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/ExecuteCommandTool.ts:149` + +### `write_to_file` handling chain + +1. Dispatch call: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:681` +2. Tool implementation class: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/WriteToFileTool.ts:26` +3. Core execution logic: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/WriteToFileTool.ts:29` +4. Write approval/save path: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/WriteToFileTool.ts:130` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/WriteToFileTool.ts:169` + +## 4. Where Tool Schemas Are Declared + +Tool schema and descriptions are defined as native function-tool definitions: + +- `execute_command` schema: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/execute_command.ts:22` +- `write_to_file` schema: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/write_to_file.ts:18` +- Included in default native tools list: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/index.ts:42` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/index.ts:56` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/index.ts:70` +- Typed tool args map: + - `/Users/gersumasfaw/Roo-Code-10x/src/shared/tools.ts:91` + - `/Users/gersumasfaw/Roo-Code-10x/src/shared/tools.ts:96` + - `/Users/gersumasfaw/Roo-Code-10x/src/shared/tools.ts:117` + +Tool filtering/composition before API call: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/task/build-tools.ts:82` + +## 5. Locate Prompt Builder (Required by Phase 0) + +The runtime prompt used for actual model calls is built in: + +- `Task.getSystemPrompt()`: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3745` + - calls `SYSTEM_PROMPT(...)` at `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3792` + +Prompt assembly implementation: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:41` (`generatePrompt`) +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:112` (`SYSTEM_PROMPT`) + +Prompt preview endpoint from settings UI: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:1595` +- `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/generateSystemPrompt.ts:12` + +## 6. Phase 1 Hook Insertion Candidates (for next step) + +These are the safest insertion points for deterministic hook middleware: + +1. Pre-tool interception (best central point): + + - immediately before per-tool switch dispatch in + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:678` + +2. Post-tool interception: + + - immediately after each `tool.handle(...)` returns in the same switch. + +3. Prompt protocol enforcement: + - in `SYSTEM_PROMPT` composition: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:85` + - and/or in task-level `getSystemPrompt()` wrapper: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3745` + +## 7. Phase 0 Completion Checklist + +- [x] Fork & run path identified and build verified. +- [x] Exact host tool-loop function identified. +- [x] Exact `execute_command` handler path identified. +- [x] Exact `write_to_file` handler path identified. +- [x] Exact system prompt builder path identified. +- [x] `ARCHITECTURE_NOTES.md` delivered. + +## 8. Architecture Specification Alignment Update + +This section aligns the codebase to your required architecture: + +- strict privilege separation, +- hook middleware boundary, +- mandatory intent handshake before writes, +- `.orchestration/` sidecar state. + +### 8.1 Privilege Separation (Mapped to Current Code) + +Webview (UI, restricted presentation layer): + +- Receives state/events via webview messaging: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/ClineProvider.ts:1127` +- User actions are handled as typed messages: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:468` + +Extension Host (logic, API + MCP + execution): + +- Provider/task orchestration is in extension host: + - `/Users/gersumasfaw/Roo-Code-10x/src/extension.ts:120` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2511` +- LLM call path: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3988` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4279` +- MCP initialization/management: + - `/Users/gersumasfaw/Roo-Code-10x/src/services/mcp/McpServerManager.ts:20` + +Hook Engine (middleware boundary for all tools): + +- Central interception location is `presentAssistantMessage(...)` before dispatch: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:678` +- This is the correct place to inject `PreToolUse` and `PostToolUse`. + +### 8.2 `.orchestration/` Sidecar Contract (Required) + +Implemented machine-managed sidecar files under `task.cwd` (plus shared brain at workspace root): + +- `.orchestration/active_intents.yaml` +- `.orchestration/agent_trace.jsonl` +- `.orchestration/intent_map.md` +- `AGENT.md` + +Implementation location: + +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/OrchestrationStore.ts` +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/IntentContextService.ts` + +Reason: + +- keep file IO and schema logic out of tool classes and out of Webview code. + +### 8.3 Two-Stage State Machine Per Turn (Required) + +State 1: Request + +- User sends prompt from webview (`newTask`/`askResponse`) into task loop. +- Entry points: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/webview/webviewMessageHandler.ts:549` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:2477` + +State 2: Reasoning Intercept (Handshake) + +- Agent must call mandatory tool: `select_active_intent(intent_id)`. +- Tool and parser are implemented and wired: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/SelectActiveIntentTool.ts:12` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/NativeToolCallParser.ts:457` + - `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:802` +- Pre-hook runs before all tools and updates intent lifecycle for checkout: + - `/Users/gersumasfaw/Roo-Code-10x/src/hooks/HookEngine.ts:71` +- Execution remains paused naturally because tool execution is awaited in dispatch path. + +State 3: Contextualized Action + +- Agent continues with mutating tools (`write_to_file`, alias `write_file`). +- Alias evidence: + - `/Users/gersumasfaw/Roo-Code-10x/src/shared/tools.ts:337` +- Post-hook computes SHA-256 `content_hash`, appends trace JSONL, updates intent map/shared brain: + - `/Users/gersumasfaw/Roo-Code-10x/src/hooks/HookEngine.ts:153` + - `/Users/gersumasfaw/Roo-Code-10x/src/hooks/OrchestrationStore.ts:226` + +## 9. Phase 1 Status (Current) + +Implemented: + +1. `select_active_intent(intent_id)` tool end-to-end. +2. Hook middleware boundary (`PreToolUse`/`PostToolUse`) around central tool dispatch. +3. Intent-aware pre-write enforcement: + - blocks mutating tools when no active intent is selected, + - blocks writes outside workspace boundary, + - enforces `owned_scope` patterns. +4. Sidecar model support and lifecycle updates: + - active intents, + - append-only trace ledger, + - intent map updates, + - shared brain append. +5. Prompt-level instruction for two-stage handshake: + - `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:27` + +Remaining hardening work: + +1. Improve range-level hashing accuracy (currently whole-file hash range per touched path). +2. Add tests for hook policies and trace output. +3. Optional storage backend upgrade (SQLite/Zvec) if scale/perf requires it. + +## 10. Concrete Injection Points for This Repo + +1. Tool definitions: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/index.ts:42` +- `/Users/gersumasfaw/Roo-Code-10x/src/shared/tools.ts:91` + +2. Dispatch boundary (best hook point): + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:678` + +3. Prompt protocol enforcement (reasoning loop instruction): + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:85` +- `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:3745` + +4. API call where system prompt is consumed: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/task/Task.ts:4279` + +## 11. Updated Phase-0 Outcome + +Phase 0 mapping remains valid, and Phase 1 foundation is now implemented: + +- exact current architecture mapping, +- tool and prompt construction points, +- active hook middleware, sidecar models, and intent handshake enforcement in the extension host. diff --git a/HOOK_SYSTEM_SPEC.md b/HOOK_SYSTEM_SPEC.md new file mode 100644 index 00000000000..a3bf5bc8096 --- /dev/null +++ b/HOOK_SYSTEM_SPEC.md @@ -0,0 +1,251 @@ +# Hook System Spec + +Date: 2026-02-17 +Repository: `/Users/gersumasfaw/Roo-Code-10x` + +## Purpose + +Define the Hook Engine middleware boundary for tool execution in the VS Code extension host, including: + +1. Two-stage intent handshake before mutating actions. +2. Pre/Post hook contracts. +3. Sidecar storage schemas under `.orchestration/`. +4. Traceability requirements linking intent -> code changes. + +## Architecture Boundary + +```mermaid +flowchart TB + UI["Webview UI (Restricted)"] -->|postMessage| HOST["Extension Host (Logic)"] + HOST --> DISPATCH["Tool Dispatch: presentAssistantMessage(...)"] + DISPATCH --> PRE["HookEngine.preToolUse(...)"] + PRE -->|allow| TOOL["Tool Implementation"] + PRE -->|deny| ERR["tool_result error"] + TOOL --> POST["HookEngine.postToolUse(...)"] + POST --> SIDECAR[".orchestration sidecar"] +``` + +Separation of concerns: + +- Webview UI: presentation only, emits events. +- Extension Host: API polling, secret management, MCP execution, task loop. +- Hook Engine: middleware guardrail for tool execution. + +Implementation anchors: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:63` +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/HookEngine.ts:73` + +## Two-Stage Turn State Machine + +```mermaid +stateDiagram-v2 + [*] --> Request + Request --> Handshake: "Model calls select_active_intent(intent_id)" + Handshake --> Action: "Pre-hook validates/injects intent context" + Action --> [*]: "Post-hook logs trace and state evolution" + Action --> Handshake: "If intent changes, re-checkout" +``` + +Handshake rule: + +- Before mutating tools, model must call `select_active_intent`. +- Mutating tools are blocked until active intent is valid. + +Prompt-level enforcement anchor: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/system.ts:30` + +## Hook Lifecycle + +```mermaid +sequenceDiagram + participant Model + participant Parser as NativeToolCallParser + participant Dispatch as presentAssistantMessage + participant Hook as HookEngine + participant Tool as ToolClass + participant Sidecar as .orchestration/* + + Model->>Parser: stream tool call + Parser->>Dispatch: ToolUse block + Dispatch->>Hook: preToolUse(task, block) + Hook-->>Dispatch: allow/deny + context + + alt denied + Dispatch-->>Model: tool_result error + else allowed + Dispatch->>Tool: handle(...) + Tool-->>Dispatch: result / failure + Dispatch->>Hook: postToolUse(task, block, context, executionSucceeded) + Hook->>Sidecar: update intents, trace, map, AGENT + end +``` + +## Pre-Hook Decision Logic + +```mermaid +flowchart TD + S["Incoming ToolUse"] --> A{"Tool is mutating?"} + A -->|No| OK["Allow execution"] + A -->|Yes| B{"activeIntentId set?"} + B -->|No| DENY1["Deny: select_active_intent required"] + B -->|Yes| C{"Intent exists in active_intents.yaml?"} + C -->|No| DENY2["Deny: active intent missing"] + C -->|Yes| D{"Touched paths in owned_scope?"} + D -->|No| DENY3["Deny + AGENT scope violation entry"] + D -->|Yes| OK +``` + +## Sidecar Data Model + +### 1) `.orchestration/active_intents.yaml` + +Canonical 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" + - "Must maintain backward compatibility with Basic Auth" + acceptance_criteria: + - "Unit tests in tests/auth/ pass" + related_files: + - "src/auth/middleware.ts" + recent_history: + - "PRE_HOOK write_to_file" +``` + +Compatibility behavior: + +- Loader accepts legacy `intents` and normalizes: + `intent_id -> id`, `title -> name`. + +Implementation anchor: + +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/OrchestrationStore.ts:133` + +### 2) `.orchestration/agent_trace.jsonl` (append-only) + +Record schema (one JSON object per line): + +```json +{ + "id": "uuid-v4", + "timestamp": "2026-02-16T12:00:00Z", + "vcs": { "revision_id": "git_sha_hash" }, + "files": [ + { + "relative_path": "src/auth/middleware.ts", + "conversations": [ + { + "url": "task_or_session_id", + "contributor": { + "entity_type": "AI", + "model_identifier": "model-id" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 45, + "content_hash": "sha256:..." + } + ], + "related": [ + { + "type": "specification", + "value": "INT-001" + } + ] + } + ] + } + ] +} +``` + +Current behavior: + +- SHA-256 is computed from each touched file content. +- One range entry is emitted per touched file. + +Implementation anchors: + +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/HookEngine.ts:281` +- `/Users/gersumasfaw/Roo-Code-10x/src/hooks/OrchestrationStore.ts:226` + +### 3) `.orchestration/intent_map.md` + +Purpose: + +- Incremental spatial mapping from intent ID to touched file paths. + +Update trigger: + +- Post-hook on successful mutating tool execution. + +### 4) `AGENT.md` (workspace root shared brain) + +Purpose: + +- Capture persistent lessons/failures/architectural decisions. + +Update triggers: + +- Scope violations, failed mutating actions, completion evolution. + +## Hook Runtime Contracts + +Pre-hook result: + +```json +{ + "allowExecution": true, + "errorMessage": "optional string", + "context": { + "toolName": "write_to_file", + "isMutatingTool": true, + "intentId": "INT-001", + "touchedPaths": ["src/auth/middleware.ts"], + "hadToolFailureBefore": false + } +} +``` + +Post-hook inputs: + +- `task` +- `block` (`ToolUse`) +- `context` (pre-hook context) +- `executionSucceeded` (boolean) + +Post-hook side effects: + +1. Append recent history to active intent. +2. For mutating success: append `agent_trace.jsonl` and `intent_map.md`. +3. For failures/scope issues/completion transitions: append `AGENT.md`. + +## Required Tooling for Handshake + +Mandatory tool: + +- `select_active_intent(intent_id: string)` + +Implementation anchors: + +- `/Users/gersumasfaw/Roo-Code-10x/src/core/tools/SelectActiveIntentTool.ts:12` +- `/Users/gersumasfaw/Roo-Code-10x/src/core/prompts/tools/native-tools/select_active_intent.ts:10` +- `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/NativeToolCallParser.ts:457` +- `/Users/gersumasfaw/Roo-Code-10x/src/core/assistant-message/presentAssistantMessage.ts:821` + +## Current Limitations + +1. Trace ranges are file-level, not AST/subrange-level. +2. Dedicated automated tests for hook policy and ledger output should be added. +3. Optional future backend: SQLite/Zvec if sidecar file scale becomes a bottleneck. diff --git a/active_intents.yaml b/active_intents.yaml new file mode 100644 index 00000000000..d04d8e1bb6a --- /dev/null +++ b/active_intents.yaml @@ -0,0 +1,14 @@ +active_intents: + - id: "auth-refactor" + name: "Refactor auth flow" + status: "IN_PROGRESS" + owned_scope: + - "src/integrations/openai-codex/**" + constraints: + - "Keep public API unchanged" + acceptance_criteria: + - "No regressions in auth flow" + related_files: + - "src/integrations/openai-codex/oauth.ts" + recent_history: + - "Investigated token handling" diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9fc..907eec48e55 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -38,6 +38,7 @@ export const toolNames = [ "access_mcp_resource", "ask_followup_question", "attempt_completion", + "select_active_intent", "switch_mode", "new_task", "codebase_search", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0ea1383f17..fdcbc7c2e9f 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -454,6 +454,12 @@ export class NativeToolCallParser { } break + case "select_active_intent": + if (partialArgs.intent_id !== undefined) { + nativeArgs = { intent_id: partialArgs.intent_id } + } + break + case "execute_command": if (partialArgs.command) { nativeArgs = { @@ -782,6 +788,14 @@ export class NativeToolCallParser { } break + case "select_active_intent": + if (args.intent_id !== undefined) { + nativeArgs = { + intent_id: args.intent_id, + } as NativeArgsFor + } + break + case "execute_command": if (args.command) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..ba026a54261 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -27,6 +27,7 @@ import { executeCommandTool } from "../tools/ExecuteCommandTool" import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" +import { selectActiveIntentTool } from "../tools/SelectActiveIntentTool" import { switchModeTool } from "../tools/SwitchModeTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" @@ -37,6 +38,7 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { hookEngine, type HookPreToolUseContext } from "../../hooks/HookEngine" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" @@ -363,6 +365,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.question}']` case "attempt_completion": return `[${block.name}]` + case "select_active_intent": + return `[${block.name} '${block.params.intent_id}']` case "switch_mode": return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` case "codebase_search": @@ -675,245 +679,284 @@ 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, - }) - break - case "update_todo_list": - await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "apply_diff": - await checkpointSaveAndMark(cline) - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "edit": - case "search_and_replace": - await checkpointSaveAndMark(cline) - await editTool.handle(cline, block as ToolUse<"edit">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "search_replace": - await checkpointSaveAndMark(cline) - await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "edit_file": - await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "apply_patch": - await checkpointSaveAndMark(cline) - await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "read_file": - // Type assertion is safe here because we're in the "read_file" case - await readFileTool.handle(cline, block as ToolUse<"read_file">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "list_files": - await listFilesTool.handle(cline, block as ToolUse<"list_files">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "codebase_search": - await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "search_files": - await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "execute_command": - await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "read_command_output": - await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "use_mcp_tool": - await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "access_mcp_resource": - await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "ask_followup_question": - await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "switch_mode": - await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "new_task": - await checkpointSaveAndMark(cline) - await newTaskTool.handle(cline, block as ToolUse<"new_task">, { - askApproval, - handleError, - pushToolResult, - toolCallId: block.id, - }) - break - case "attempt_completion": { - const completionCallbacks: AttemptCompletionCallbacks = { - askApproval, - handleError, - pushToolResult, - askFinishSubTaskApproval, - toolDescription, - } - await attemptCompletionTool.handle( - cline, - block as ToolUse<"attempt_completion">, - completionCallbacks, - ) + let hookContext: HookPreToolUseContext | undefined + if (!block.partial) { + const preToolUseResult = await hookEngine.preToolUse(cline, block) + if (!preToolUseResult.allowExecution) { + const errorMessage = preToolUseResult.errorMessage || `PreToolUse denied tool ${block.name}` + cline.consecutiveMistakeCount++ + cline.didToolFailInCurrentTurn = true + cline.recordToolError(block.name as ToolName, errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) break } - case "run_slash_command": - await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "skill": - await skillTool.handle(cline, block as ToolUse<"skill">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "generate_image": - await checkpointSaveAndMark(cline) - await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { - askApproval, - handleError, - pushToolResult, - }) - break - default: { - // Handle unknown/invalid tool names OR custom tools - // This is critical for native tool calling where every tool_use MUST have a tool_result - - // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. - // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, - // creating a loop that appears to freeze the extension. Only handle complete blocks. - if (block.partial) { + hookContext = preToolUseResult.context + } + + const didToolFailBeforeExecution = cline.didToolFailInCurrentTurn + let dispatchError: unknown + + try { + switch (block.name) { + case "write_to_file": + await checkpointSaveAndMark(cline) + await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "update_todo_list": + await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "apply_diff": + await checkpointSaveAndMark(cline) + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "edit": + case "search_and_replace": + await checkpointSaveAndMark(cline) + await editTool.handle(cline, block as ToolUse<"edit">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "search_replace": + await checkpointSaveAndMark(cline) + await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "edit_file": + await checkpointSaveAndMark(cline) + await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "apply_patch": + await checkpointSaveAndMark(cline) + await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "read_file": + // Type assertion is safe here because we're in the "read_file" case + await readFileTool.handle(cline, block as ToolUse<"read_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "list_files": + await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "codebase_search": + await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "search_files": + await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "execute_command": + await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "read_command_output": + await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "use_mcp_tool": + await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "access_mcp_resource": + await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "ask_followup_question": + await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "select_active_intent": + await selectActiveIntentTool.handle(cline, block as ToolUse<"select_active_intent">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "switch_mode": + await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "new_task": + await checkpointSaveAndMark(cline) + await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + askApproval, + handleError, + pushToolResult, + toolCallId: block.id, + }) + break + case "attempt_completion": { + const completionCallbacks: AttemptCompletionCallbacks = { + askApproval, + handleError, + pushToolResult, + askFinishSubTaskApproval, + toolDescription, + } + await attemptCompletionTool.handle( + cline, + block as ToolUse<"attempt_completion">, + completionCallbacks, + ) break } + case "run_slash_command": + await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "skill": + await skillTool.handle(cline, block as ToolUse<"skill">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "generate_image": + await checkpointSaveAndMark(cline) + await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + askApproval, + handleError, + pushToolResult, + }) + break + default: { + // Handle unknown/invalid tool names OR custom tools + // This is critical for native tool calling where every tool_use MUST have a tool_result + + // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. + // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, + // creating a loop that appears to freeze the extension. Only handle complete blocks. + if (block.partial) { + break + } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined - - if (customTool) { - try { - let customToolArgs - - if (customTool.parameters) { - try { - customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) - } catch (parseParamsError) { - const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` - console.error(message) - cline.consecutiveMistakeCount++ - await cline.say("error", message) - pushToolResult(formatResponse.toolError(message)) - break + const customTool = stateExperiments?.customTools + ? customToolRegistry.get(block.name) + : undefined + + if (customTool) { + try { + let customToolArgs + + if (customTool.parameters) { + try { + customToolArgs = customTool.parameters.parse( + block.nativeArgs || block.params || {}, + ) + } catch (parseParamsError) { + const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` + console.error(message) + cline.consecutiveMistakeCount++ + await cline.say("error", message) + pushToolResult(formatResponse.toolError(message)) + break + } } + + const result = await customTool.execute(customToolArgs, { + mode: mode ?? defaultModeSlug, + task: cline, + }) + + console.log( + `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, + ) + + pushToolResult(result) + cline.consecutiveMistakeCount = 0 + } catch (executionError: any) { + cline.consecutiveMistakeCount++ + // Record custom tool error with static name + cline.recordToolError("custom_tool", executionError.message) + await handleError(`executing custom tool "${block.name}"`, executionError) } - const result = await customTool.execute(customToolArgs, { - mode: mode ?? defaultModeSlug, - task: cline, - }) - - console.log( - `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, - ) - - pushToolResult(result) - cline.consecutiveMistakeCount = 0 - } catch (executionError: any) { - cline.consecutiveMistakeCount++ - // Record custom tool error with static name - cline.recordToolError("custom_tool", executionError.message) - await handleError(`executing custom tool "${block.name}"`, executionError) + break } + // Not a custom tool - handle as unknown tool error + const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` + cline.consecutiveMistakeCount++ + cline.recordToolError(block.name as ToolName, errorMessage) + await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) + // Push tool_result directly WITHOUT setting didAlreadyUseTool + // This prevents the stream from being interrupted with "Response interrupted by tool use result" + cline.pushToolResultToUserContent({ + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: formatResponse.toolError(errorMessage), + is_error: true, + }) break } - - // Not a custom tool - handle as unknown tool error - const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` - cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) - await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) - // Push tool_result directly WITHOUT setting didAlreadyUseTool - // This prevents the stream from being interrupted with "Response interrupted by tool use result" - cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: formatResponse.toolError(errorMessage), - is_error: true, - }) - break + } + } catch (error) { + dispatchError = error + throw error + } finally { + if (!block.partial && hookContext) { + const didFailInThisDispatch = cline.didToolFailInCurrentTurn && !didToolFailBeforeExecution + const executionSucceeded = !dispatchError && !didFailInThisDispatch + await hookEngine.postToolUse(cline, block, hookContext, executionSucceeded) } } diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a9..5c92dc1be99 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -25,6 +25,13 @@ import { getSkillsSection, } from "./sections" +function getIntentHandshakeSection(): string { + return ` +Before using any mutating tool (write_to_file, apply_diff, edit, search_and_replace, search_replace, edit_file, apply_patch, generate_image), you must call select_active_intent with an intent id. +Do not call mutating tools until select_active_intent has succeeded for the current turn. +` +} + // Helper function to get prompt component, filtering out empty objects export function getPromptComponent( customModePrompts: CustomModePrompts | undefined, @@ -88,6 +95,8 @@ ${markdownFormattingSection()} ${getSharedToolUseSection()}${toolsCatalog} +${getIntentHandshakeSection()} + ${getToolUseGuidelinesSection()} ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)} diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..64a030a0869 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -17,6 +17,7 @@ import skill from "./skill" import searchReplace from "./search_replace" import edit_file from "./edit_file" import searchFiles from "./search_files" +import selectActiveIntent from "./select_active_intent" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" @@ -65,6 +66,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch edit_file, editTool, searchFiles, + selectActiveIntent, switchMode, updateTodoList, writeToFile, diff --git a/src/core/prompts/tools/native-tools/select_active_intent.ts b/src/core/prompts/tools/native-tools/select_active_intent.ts new file mode 100644 index 00000000000..d282f290b1f --- /dev/null +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -0,0 +1,25 @@ +import type OpenAI from "openai" + +const SELECT_ACTIVE_INTENT_DESCRIPTION = `Select and checkout the active intent for this turn before performing any code edits. This tool loads intent constraints, related files, and recent history from .orchestration/active_intents.yaml.` + +const INTENT_ID_PARAMETER_DESCRIPTION = `The intent identifier to activate for this turn` + +export default { + type: "function", + function: { + name: "select_active_intent", + description: SELECT_ACTIVE_INTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: INTENT_ID_PARAMETER_DESCRIPTION, + }, + }, + required: ["intent_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac3..8b83163969a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1,6 +1,5 @@ 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" @@ -90,6 +89,7 @@ import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/ import { getWorkspacePath } from "../../utils/path" import { sanitizeToolUseId } from "../../utils/tool-id" import { getTaskDirectoryPath } from "../../utils/storage" +import { appendChatTrace, compact as compactTrace } from "../../utils/chatTrace" // prompts import { formatResponse } from "../prompts/responses" @@ -137,6 +137,7 @@ const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +export type IntentCheckoutStage = "checkout_required" | "execution_authorized" export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider @@ -322,6 +323,9 @@ export class Task extends EventEmitter implements TaskLike { consecutiveMistakeLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() consecutiveMistakeCountForEditFile: Map = new Map() + private _activeIntentId: string | undefined + private _intentCheckoutStage: IntentCheckoutStage = "checkout_required" + private _pendingIntentHandshakeContext?: string consecutiveNoToolUseCount: number = 0 consecutiveNoAssistantMessagesCount: number = 0 toolUsage: ToolUsage = {} @@ -472,9 +476,7 @@ export class Task extends EventEmitter implements TaskLike { } // Normal use-case is usually retry similar history task with new workspace. - this.workspacePath = parentTask - ? parentTask.workspacePath - : (workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop"))) + this.workspacePath = parentTask ? parentTask.workspacePath : (workspacePath ?? getWorkspacePath()) this.instanceId = crypto.randomUUID().slice(0, 8) this.taskNumber = -1 @@ -843,6 +845,39 @@ export class Task extends EventEmitter implements TaskLike { this._taskApiConfigName = apiConfigName } + public get activeIntentId(): string | undefined { + return this._activeIntentId + } + + public setActiveIntentId(intentId: string | undefined): void { + this._activeIntentId = intentId?.trim() || undefined + } + + public getIntentCheckoutStage(): IntentCheckoutStage { + return this._intentCheckoutStage + } + + public resetIntentCheckoutForTurn(): void { + this._intentCheckoutStage = "checkout_required" + } + + public authorizeIntentCheckoutForTurn(intentId?: string): void { + if (intentId) { + this.setActiveIntentId(intentId) + } + this._intentCheckoutStage = "execution_authorized" + } + + public setPendingIntentHandshakeContext(context: string | undefined): void { + this._pendingIntentHandshakeContext = context?.trim() || undefined + } + + public consumePendingIntentHandshakeContext(): string | undefined { + const value = this._pendingIntentHandshakeContext + this._pendingIntentHandshakeContext = undefined + return value + } + static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options @@ -1167,6 +1202,16 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() + // Append compact trace entry for chat activity (non-blocking) + try { + void appendChatTrace( + this.cwd, + `message:created taskId=${this.taskId} type=${message.type}${message.say ? `/${message.say}` : ""} ts=${message.ts} text="${compactTrace(message.text)}"`, + ) + } catch (err) { + /* swallow */ + } + const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() if (shouldCaptureMessage) { @@ -2766,6 +2811,7 @@ export class Task extends EventEmitter implements TaskLike { this.didRejectTool = false this.didAlreadyUseTool = false this.assistantMessageSavedToHistory = false + this.resetIntentCheckoutForTurn() // Reset tool failure flag for each new assistant turn - this ensures that tool failures // only prevent attempt_completion within the same assistant message, not across turns // (e.g., if a tool fails, then user sends a message saying "just complete anyway") diff --git a/src/core/tools/SelectActiveIntentTool.ts b/src/core/tools/SelectActiveIntentTool.ts new file mode 100644 index 00000000000..7f47096963e --- /dev/null +++ b/src/core/tools/SelectActiveIntentTool.ts @@ -0,0 +1,80 @@ +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { OrchestrationStore } from "../../hooks/OrchestrationStore" +import { IntentContextService } from "../../hooks/IntentContextService" + +interface SelectActiveIntentParams { + intent_id: string +} + +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { + readonly name = "select_active_intent" as const + + private resolveWorkspacePath(task: Task): string | undefined { + const fromWorkspacePath = (task as Task & { workspacePath?: string }).workspacePath + if (typeof fromWorkspacePath === "string" && fromWorkspacePath.trim().length > 0) { + return fromWorkspacePath.trim() + } + + const cwd = task.cwd + if (typeof cwd === "string" && cwd.trim().length > 0) { + return cwd.trim() + } + + return undefined + } + + async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult } = callbacks + const intentId = params.intent_id?.trim() + + try { + if (!intentId) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("select_active_intent", "intent_id")) + return + } + + const workspacePath = this.resolveWorkspacePath(task) + if (!workspacePath) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError("Cannot resolve workspace path for select_active_intent.")) + return + } + + const store = new OrchestrationStore(workspacePath) + const intentContextService = new IntentContextService(store) + const result = await intentContextService.selectIntent(intentId) + + if (!result.found || !result.context) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError(result.message)) + return + } + + task.setActiveIntentId(result.context.id) + task.authorizeIntentCheckoutForTurn(result.context.id) + await intentContextService.markIntentInProgress(result.context.id) + task.consecutiveMistakeCount = 0 + const interceptedContext = task.consumePendingIntentHandshakeContext() + pushToolResult(interceptedContext ?? result.message) + } catch (error) { + await handleError("selecting active intent", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"select_active_intent">): Promise { + const intentId = block.params.intent_id ?? "" + await task.ask("tool", JSON.stringify({ tool: "selectActiveIntent", intentId }), block.partial).catch(() => {}) + } +} + +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/core/tools/__tests__/selectActiveIntentTool.spec.ts b/src/core/tools/__tests__/selectActiveIntentTool.spec.ts new file mode 100644 index 00000000000..7c929df15cf --- /dev/null +++ b/src/core/tools/__tests__/selectActiveIntentTool.spec.ts @@ -0,0 +1,66 @@ +import { selectActiveIntentTool } from "../SelectActiveIntentTool" +import type { ToolUse } from "../../../shared/tools" + +const selectIntent = vi.fn() +const markIntentInProgress = vi.fn() + +vi.mock("../../../hooks/IntentContextService", () => ({ + IntentContextService: class { + async selectIntent(intentId: string) { + return selectIntent(intentId) + } + async markIntentInProgress(intentId: string) { + return markIntentInProgress(intentId) + } + }, +})) + +vi.mock("../../../hooks/OrchestrationStore", () => ({ + OrchestrationStore: class {}, +})) + +describe("selectActiveIntentTool", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("uses pre-hook injected handshake context when available", async () => { + selectIntent.mockResolvedValue({ + found: true, + context: { id: "INT-1" }, + availableIntentIds: ["INT-1"], + message: "Selected active intent context", + }) + markIntentInProgress.mockResolvedValue(undefined) + + const task = { + cwd: "/workspace", + workspacePath: "/workspace", + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + setActiveIntentId: vi.fn(), + authorizeIntentCheckoutForTurn: vi.fn(), + consumePendingIntentHandshakeContext: vi.fn().mockReturnValue("Injected deep context from pre-hook"), + sayAndCreateMissingParamError: vi.fn(), + } as any + + const pushToolResult = vi.fn() + const block: ToolUse<"select_active_intent"> = { + type: "tool_use", + name: "select_active_intent", + params: { intent_id: "INT-1" }, + partial: false, + nativeArgs: { intent_id: "INT-1" }, + } + + await selectActiveIntentTool.handle(task, block, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult, + }) + + expect(task.setActiveIntentId).toHaveBeenCalledWith("INT-1") + expect(task.authorizeIntentCheckoutForTurn).toHaveBeenCalledWith("INT-1") + expect(pushToolResult).toHaveBeenCalledWith("Injected deep context from pre-hook") + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bb9199a65c2..4e611365975 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2987,6 +2987,7 @@ export class ClineProvider enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), initialTodos: options.initialTodos, ...options, + workspacePath: this.currentWorkspacePath ?? getWorkspacePath(), }) await this.addClineToStack(task) diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts new file mode 100644 index 00000000000..0179bff56dd --- /dev/null +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -0,0 +1,102 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, expect, it, vi } from "vitest" + +import { generateSystemPrompt } from "../generateSystemPrompt" +import { SYSTEM_PROMPT } from "../../prompts/system" + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(() => ({ + getModel: () => ({ info: {} }), + })), +})) + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn((key: string, fallback: unknown) => fallback), + }), + }, +})) + +describe("generateSystemPrompt governance context injection", () => { + it("injects sidecar constraints, active intent and shared brain excerpt into custom instructions", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "roo-generate-prompt-")) + const orchestrationDir = path.join(cwd, ".orchestration") + await fs.mkdir(orchestrationDir, { recursive: true }) + + await fs.writeFile( + path.join(orchestrationDir, "active_intents.yaml"), + [ + "active_intents:", + " - id: INT-42", + " status: IN_PROGRESS", + ' owned_scope: ["src/**"]', + ' constraints: ["No direct infra changes"]', + ' acceptance_criteria: ["tests pass"]', + " recent_history: []", + " related_files: []", + "", + ].join("\n"), + "utf8", + ) + + await fs.writeFile( + path.join(orchestrationDir, "constraints.sidecar.yaml"), + [ + "sidecar:", + " version: 2", + " architectural_constraints:", + " - Keep module boundaries explicit.", + " blocked_tools: []", + " deny_mutations: []", + "", + ].join("\n"), + "utf8", + ) + + await fs.writeFile(path.join(cwd, "AGENT.md"), "# AGENT\n- prior architectural decision", "utf8") + + const provider = { + cwd, + context: {}, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { todoListEnabled: true }, + customModePrompts: undefined, + customInstructions: "user custom instructions", + mcpEnabled: false, + experiments: undefined, + language: "en", + enableSubfolderRules: false, + }), + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + }, + getCurrentTask: vi.fn().mockReturnValue({ + activeIntentId: "INT-42", + rooIgnoreController: undefined, + }), + getMcpHub: vi.fn(), + getSkillsManager: vi.fn().mockReturnValue(undefined), + } as any + + const result = await generateSystemPrompt(provider, { mode: "code" } as any) + + expect(result).toBe("mocked system prompt") + expect(SYSTEM_PROMPT).toHaveBeenCalledTimes(1) + const callArgs = vi.mocked(SYSTEM_PROMPT).mock.calls[0] + const mergedCustomInstructions = callArgs[8] as string + + expect(mergedCustomInstructions).toContain("user custom instructions") + expect(mergedCustomInstructions).toContain("Governance Sidecar Context") + expect(mergedCustomInstructions).toContain("sidecar_version: 2") + expect(mergedCustomInstructions).toContain("INT-42") + expect(mergedCustomInstructions).toContain("Keep module boundaries explicit.") + expect(mergedCustomInstructions).toContain("prior architectural decision") + }) +}) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d5..9a17aeab539 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,7 +1,9 @@ import * as vscode from "vscode" +import fs from "fs/promises" import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" +import { OrchestrationStore } from "../../hooks/OrchestrationStore" import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -9,6 +11,108 @@ import { Package } from "../../shared/package" import { ClineProvider } from "./ClineProvider" +const MAX_CONSTRAINTS_IN_PROMPT = 8 +const MAX_SHARED_BRAIN_LINES = 12 +const MAX_SHARED_BRAIN_CHARS = 1200 + +function truncateByChars(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value + } + return `${value.slice(0, maxChars)}...` +} + +function toBulletList(items: string[]): string { + if (items.length === 0) { + return "- (none)" + } + return items.map((item) => `- ${item}`).join("\n") +} + +async function getSharedBrainExcerpt(sharedBrainPath: string): Promise { + try { + const raw = await fs.readFile(sharedBrainPath, "utf8") + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-MAX_SHARED_BRAIN_LINES) + + if (lines.length === 0) { + return "(none)" + } + + return truncateByChars(lines.join("\n"), MAX_SHARED_BRAIN_CHARS) + } catch { + return "(unavailable)" + } +} + +async function buildGovernanceContextInstructions(provider: ClineProvider): Promise { + const cwd = provider.cwd + if (!cwd) { + return undefined + } + + try { + const store = new OrchestrationStore(cwd) + await store.ensureInitialized() + const [sidecar, intents, sharedBrainExcerpt] = await Promise.all([ + store.loadSidecarPolicy(), + store.loadIntents(), + getSharedBrainExcerpt(store.sharedBrainPath), + ]) + + const activeIntentId = provider.getCurrentTask()?.activeIntentId?.trim() + const selectedIntent = + (activeIntentId ? intents.find((intent) => intent.id === activeIntentId) : undefined) ?? + intents.find((intent) => intent.status === "IN_PROGRESS") ?? + intents.find((intent) => intent.status === "PENDING") + + const intentSummary = selectedIntent + ? [ + `id: ${selectedIntent.id}`, + `status: ${selectedIntent.status ?? "PENDING"}`, + `owned_scope: ${ + selectedIntent.owned_scope.length > 0 ? selectedIntent.owned_scope.join(", ") : "(none)" + }`, + `acceptance_criteria: ${ + selectedIntent.acceptance_criteria.length > 0 + ? selectedIntent.acceptance_criteria.join(" | ") + : "(none)" + }`, + ].join("\n") + : "(no active intent)" + + const constraints = sidecar.architectural_constraints.slice(0, MAX_CONSTRAINTS_IN_PROMPT) + const constraintList = toBulletList(constraints) + + return [ + "## Governance Sidecar Context (Auto-Injected)", + "", + "", + `sidecar_version: ${sidecar.version}`, + "architectural_constraints:", + constraintList, + "", + "active_intent_summary:", + intentSummary, + "", + "shared_brain_recent:", + sharedBrainExcerpt, + "", + "context_rot_guardrails:", + "- Treat sidecar and active intent context as authoritative constraints for mutating work.", + "- If chat instructions conflict with governance context, follow governance context and ask follow-up.", + "- Keep new reasoning scoped to current active intent and owned scope.", + "", + ].join("\n") + } catch (error) { + console.error("Failed to build governance sidecar context for system prompt:", error) + return undefined + } +} + export const generateSystemPrompt = async (provider: ClineProvider, message: WebviewMessage) => { const { apiConfiguration, @@ -26,6 +130,8 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web const mode = message.mode ?? defaultModeSlug const customModes = await provider.customModesManager.getCustomModes() + const governanceContextInstructions = await buildGovernanceContextInstructions(provider) + const mergedCustomInstructions = [customInstructions, governanceContextInstructions].filter(Boolean).join("\n\n") const rooIgnoreInstructions = provider.getCurrentTask()?.rooIgnoreController?.getInstructions() @@ -48,7 +154,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web mode, customModePrompts, customModes, - customInstructions, + mergedCustomInstructions, experiments, language, rooIgnoreInstructions, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dc8f073bf11..faed12e05c4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -62,6 +62,7 @@ import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" +import { appendChatTrace, compact as compactTrace } from "../../utils/chatTrace" import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" @@ -100,6 +101,26 @@ export const webviewMessageHandler = async ( return provider.getCurrentTask()?.cwd || provider.cwd } + // Trace incoming webview messages (compact summary saved to workspace logs/chat-trace.log) + try { + const cwd = getCurrentCwd() || provider.cwd || process.cwd() + let payloadSummary = "" + try { + if ((message as any).text) payloadSummary = compactTrace((message as any).text) + else if ((message as any).askResponse) payloadSummary = String((message as any).askResponse) + else payloadSummary = compactTrace(JSON.stringify(message)) + } catch (e) { + payloadSummary = "" + } + + void appendChatTrace( + cwd, + `incoming:webview type=${message.type}${payloadSummary ? ` payload=\"${payloadSummary}\"` : ""}`, + ) + } catch (err) { + // swallow + } + /** * Resolves image file mentions in incoming messages. * Matches read_file behavior: respects size limits and model capabilities. diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts new file mode 100644 index 00000000000..35c4eb3f7a9 --- /dev/null +++ b/src/hooks/HookEngine.ts @@ -0,0 +1,683 @@ +import crypto from "crypto" +import fs from "fs/promises" +import path from "path" +import { execFile } from "child_process" +import { promisify } from "util" + +import type { ToolName } from "@roo-code/types" + +import type { ToolUse } from "../shared/tools" +import { Task } from "../core/task/Task" +import { type ActiveIntentRecord, type AgentTraceRecord, OrchestrationStore } from "./OrchestrationStore" +import { IntentContextService } from "./IntentContextService" +import { parseSourceCodeDefinitionsForFile } from "../services/tree-sitter" + +const execFileAsync = promisify(execFile) + +const MUTATING_TOOLS = new Set([ + "write_to_file", + "apply_diff", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + "generate_image", +]) + +const APPLY_PATCH_FILE_MARKERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "] as const + +interface ExtractedPaths { + insideWorkspacePaths: string[] + outsideWorkspacePaths: string[] +} + +export interface HookPreToolUseContext { + toolName: string + isMutatingTool: boolean + intentId?: string + intent?: ActiveIntentRecord + touchedPaths: string[] + sidecarConstraints: string[] + sidecarVersion: number + hadToolFailureBefore: boolean +} + +export interface HookPreToolUseResult { + allowExecution: boolean + errorMessage?: string + context: HookPreToolUseContext +} + +function normalizePathLike(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined + } + + const normalized = value.trim() + return normalized.length > 0 ? normalized : undefined +} + +function globToRegExp(globPattern: string): RegExp { + const normalized = globPattern.trim().replace(/\\/g, "/").replace(/^\.\//, "") + const escaped = normalized + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "__DOUBLE_STAR__") + .replace(/\*/g, "[^/]*") + .replace(/__DOUBLE_STAR__/g, ".*") + + return new RegExp(`^${escaped}$`) +} + +function toUnique(values: string[]): string[] { + return Array.from(new Set(values)) +} + +export class HookEngine { + async preToolUse(task: Task, block: ToolUse): Promise { + const toolName = String(block.name) + const isMutatingTool = MUTATING_TOOLS.has(block.name as ToolName) + const workspacePath = this.getWorkspacePath(task) + if (!workspacePath) { + return { + allowExecution: true, + context: { + toolName, + isMutatingTool, + touchedPaths: [], + sidecarConstraints: [], + sidecarVersion: 1, + hadToolFailureBefore: task.didToolFailInCurrentTurn, + }, + } + } + + const store = new OrchestrationStore(workspacePath) + await store.ensureInitialized() + const contract = await store.getDirectoryContractStatus() + + const extractedPaths = this.extractTouchedPaths(workspacePath, block) + const sidecar = await store.loadSidecarPolicy() + const context: HookPreToolUseContext = { + toolName, + isMutatingTool, + touchedPaths: extractedPaths.insideWorkspacePaths, + sidecarConstraints: sidecar.architectural_constraints, + sidecarVersion: sidecar.version, + hadToolFailureBefore: task.didToolFailInCurrentTurn, + } + + if (toolName === "select_active_intent") { + const requestedIntentId = this.extractRequestedIntentId(block) + if (requestedIntentId) { + const intentContextService = new IntentContextService(store) + const selectedIntent = await intentContextService.selectIntent(requestedIntentId) + if (selectedIntent.found && selectedIntent.context) { + const sidecarConstraintLines = + sidecar.architectural_constraints.length > 0 + ? sidecar.architectural_constraints.map((constraint) => `- ${constraint}`).join("\n") + : "- (none)" + const handshakeContext = [ + "Intent reasoning intercept completed.", + "", + selectedIntent.message, + "", + "Sidecar architectural constraints:", + sidecarConstraintLines, + ].join("\n") + task.setPendingIntentHandshakeContext(handshakeContext) + } + await intentContextService.markIntentInProgress(requestedIntentId) + } + + return { allowExecution: true, context } + } + + if (!isMutatingTool) { + return { allowExecution: true, context } + } + + // Two-stage turn state machine: + // stage 1: checkout_required (must call select_active_intent first) + // stage 2: execution_authorized (mutating tools allowed) + // + // Enforce strictly for real Task instances; test doubles that don't use + // Task can bypass to keep existing unit tests isolated from runtime policy. + const stage = + typeof (task as Task & { getIntentCheckoutStage?: () => string }).getIntentCheckoutStage === "function" + ? (task as Task & { getIntentCheckoutStage: () => string }).getIntentCheckoutStage() + : "execution_authorized" + if (stage !== "execution_authorized") { + await store.appendGovernanceEntry({ + intent_id: task.activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: + `PreToolUse denied ${toolName}: intent checkout required for this turn. ` + + `Call select_active_intent before mutating tools.`, + } + } + + if (!contract.isCompliant) { + const missing = contract.missingRequiredFiles + const unexpected = contract.unexpectedEntries + const contractErrorParts: string[] = [] + if (missing.length > 0) { + contractErrorParts.push(`missing required files: ${missing.join(", ")}`) + } + if (unexpected.length > 0) { + contractErrorParts.push(`unexpected entries: ${unexpected.join(", ")}`) + } + const contractError = contractErrorParts.join("; ") + + await store.appendSharedBrainEntry( + `Orchestration contract drift detected. Denied ${toolName}. Details: ${contractError}`, + ) + await store.appendGovernanceEntry({ + intent_id: task.activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: + `PreToolUse denied ${toolName}: .orchestration directory contract violation (${contractError}). ` + + `Restore required control-plane files and remove unexpected entries.`, + } + } + + if (sidecar.blocked_tools.includes(toolName)) { + await store.appendGovernanceEntry({ + intent_id: task.activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: `PreToolUse denied ${toolName}: blocked by sidecar policy v${sidecar.version}.`, + } + } + + if (extractedPaths.insideWorkspacePaths.length > 0) { + const deniedBySidecar = extractedPaths.insideWorkspacePaths.filter((relativePath) => + sidecar.deny_mutations.some((rule) => this.pathMatchesOwnedScope(relativePath, [rule.path_glob])), + ) + if (deniedBySidecar.length > 0) { + await store.appendGovernanceEntry({ + intent_id: task.activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: + `PreToolUse denied ${toolName}: sidecar policy v${sidecar.version} denies mutation for path(s): ` + + `${deniedBySidecar.join(", ")}.`, + } + } + } + + if (extractedPaths.outsideWorkspacePaths.length > 0) { + await store.appendGovernanceEntry({ + intent_id: task.activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: + `PreToolUse denied ${toolName}: attempted to mutate paths outside the workspace boundary. ` + + `Paths: ${extractedPaths.outsideWorkspacePaths.join(", ")}`, + } + } + + const activeIntentId = task.activeIntentId?.trim() + if (!activeIntentId) { + await store.appendGovernanceEntry({ + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: `PreToolUse denied ${toolName}: no active intent selected. Call select_active_intent before mutating code.`, + } + } + + const intent = await store.findIntentById(activeIntentId) + if (!intent) { + await store.appendGovernanceEntry({ + intent_id: activeIntentId, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: `PreToolUse denied ${toolName}: active intent '${activeIntentId}' not found in .orchestration/active_intents.yaml.`, + } + } + + context.intentId = intent.id + context.intent = intent + + if (intent.owned_scope.length > 0 && extractedPaths.insideWorkspacePaths.length > 0) { + const disallowedPaths = extractedPaths.insideWorkspacePaths.filter( + (filePath) => !this.pathMatchesOwnedScope(filePath, intent.owned_scope), + ) + + if (disallowedPaths.length > 0) { + await store.appendSharedBrainEntry( + `Scope violation blocked for intent ${intent.id}. Disallowed paths: ${disallowedPaths.join(", ")}`, + ) + await store.appendGovernanceEntry({ + intent_id: intent.id, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: + `PreToolUse denied ${toolName}: path(s) outside owned_scope for intent ${intent.id}. ` + + `Disallowed: ${disallowedPaths.join(", ")}`, + } + } + } + + // Explicit context injection marker for traceability in intent history. + await store.appendRecentHistory(intent.id, `INTENT_CONTEXT_INJECTED ${toolName}`) + + // Human-in-the-loop authorization gate in pre-hook for mutating tools. + // For production Task instances, we require explicit approval before tool execution. + if (typeof (task as Task & { ask?: unknown }).ask === "function") { + const hitlPayload = JSON.stringify({ + tool: "preToolAuthorization", + requested_tool: toolName, + intent_id: intent.id, + paths: context.touchedPaths, + }) + const { response, text } = await task.ask("tool", hitlPayload) + if (response !== "yesButtonClicked") { + const feedback = typeof text === "string" && text.trim().length > 0 ? ` Feedback: ${text}` : "" + await store.appendGovernanceEntry({ + intent_id: intent.id, + tool_name: toolName, + status: "DENIED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + return { + allowExecution: false, + context, + errorMessage: `PreToolUse denied ${toolName}: HITL authorization was not approved.${feedback}`, + } + } + } + + await store.appendRecentHistory(intent.id, `PRE_HOOK ${toolName}`) + return { allowExecution: true, context } + } + + async postToolUse( + task: Task, + block: ToolUse, + context: HookPreToolUseContext, + executionSucceeded: boolean, + ): Promise { + const workspacePath = this.getWorkspacePath(task) + if (!workspacePath) { + return + } + + const store = new OrchestrationStore(workspacePath) + await store.ensureInitialized() + + if (context.intentId) { + const statusLabel = executionSucceeded ? "OK" : "FAILED" + await store.appendRecentHistory(context.intentId, `POST_HOOK ${context.toolName} ${statusLabel}`) + } + await store.appendGovernanceEntry({ + intent_id: context.intentId, + tool_name: context.toolName, + status: executionSucceeded ? "OK" : "FAILED", + task_id: task.taskId, + model_identifier: task.api.getModel().id, + revision_id: await this.getGitRevision(task.cwd), + touched_paths: context.touchedPaths, + sidecar_constraints: context.sidecarConstraints, + }) + + if (context.toolName === "attempt_completion" && executionSucceeded && task.activeIntentId) { + const intentContextService = new IntentContextService(store) + await intentContextService.markIntentCompleted(task.activeIntentId) + await store.appendSharedBrainEntry(`Intent ${task.activeIntentId} marked COMPLETED by attempt_completion.`) + return + } + + if (!context.isMutatingTool || !context.intentId) { + return + } + + if (!executionSucceeded) { + await store.appendSharedBrainEntry( + `Mutating tool ${context.toolName} failed for intent ${context.intentId}. Verification or retry needed.`, + ) + return + } + + const traceRecord = await this.buildTraceRecord(task, context, block) + if (traceRecord.files.length === 0) { + return + } + + await store.appendTraceRecord(traceRecord) + if (context.intent) { + const astFingerprints = Object.fromEntries( + traceRecord.files.map((file) => [file.relative_path, file.ast_fingerprint?.summary_hash]), + ) + await store.appendIntentMapEntry(context.intent, context.touchedPaths, astFingerprints) + } + } + + private getWorkspacePath(task: Task): string | undefined { + const fromWorkspacePath = (task as Task & { workspacePath?: string }).workspacePath + if (typeof fromWorkspacePath === "string" && fromWorkspacePath.trim().length > 0) { + return fromWorkspacePath.trim() + } + + const cwd = task.cwd + if (typeof cwd === "string" && cwd.trim().length > 0) { + return cwd.trim() + } + + return undefined + } + + private extractRequestedIntentId(block: ToolUse): string | undefined { + const nativeArgs = block.nativeArgs as Record | undefined + const fromNative = normalizePathLike(nativeArgs?.intent_id) + if (fromNative) { + return fromNative + } + + return normalizePathLike(block.params.intent_id) + } + + private extractTouchedPaths(cwd: string, block: ToolUse): ExtractedPaths { + const nativeArgs = (block.nativeArgs as Record | undefined) ?? {} + const fallbackParams = block.params + const pathCandidates: string[] = [] + + const add = (value: unknown) => { + const normalized = normalizePathLike(value) + if (normalized) { + pathCandidates.push(normalized) + } + } + + switch (block.name) { + case "write_to_file": + case "apply_diff": + add(nativeArgs.path ?? fallbackParams.path) + break + + case "edit": + case "search_and_replace": + case "search_replace": + case "edit_file": + add(nativeArgs.file_path ?? fallbackParams.file_path ?? fallbackParams.path) + break + + case "generate_image": + add(nativeArgs.path ?? fallbackParams.path) + break + + case "apply_patch": { + const patch = normalizePathLike(nativeArgs.patch ?? fallbackParams.patch) + if (patch) { + for (const line of patch.split(/\r?\n/)) { + for (const marker of APPLY_PATCH_FILE_MARKERS) { + if (line.startsWith(marker)) { + add(line.slice(marker.length)) + break + } + } + } + } + break + } + + default: + break + } + + const insideWorkspacePaths: string[] = [] + const outsideWorkspacePaths: string[] = [] + + for (const candidate of toUnique(pathCandidates)) { + const normalized = this.normalizeWorkspaceRelativePath(cwd, candidate) + if (normalized) { + insideWorkspacePaths.push(normalized) + } else { + outsideWorkspacePaths.push(candidate) + } + } + + return { + insideWorkspacePaths, + outsideWorkspacePaths, + } + } + + private normalizeWorkspaceRelativePath(cwd: string, candidatePath: string): string | null { + const resolved = path.isAbsolute(candidatePath) ? path.resolve(candidatePath) : path.resolve(cwd, candidatePath) + const relative = path.relative(cwd, resolved) + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null + } + + return relative.replace(/\\/g, "/") + } + + private pathMatchesOwnedScope(relativePath: string, ownedScope: string[]): boolean { + const normalizedPath = relativePath.replace(/\\/g, "/").replace(/^\.\//, "") + return ownedScope.some((pattern) => { + const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "") + const regex = globToRegExp(normalizedPattern) + return regex.test(normalizedPath) + }) + } + + private extractToolPayloadForPath(block: ToolUse, relativePath: string): string | undefined { + const nativeArgs = (block.nativeArgs as Record | undefined) ?? {} + const params = block.params + const normalizedRelativePath = relativePath.replace(/\\/g, "/") + + const normalizeCandidatePath = (value: unknown): string | undefined => { + const normalized = normalizePathLike(value) + return normalized ? normalized.replace(/\\/g, "/").replace(/^\.\//, "") : undefined + } + + const toolPath = + normalizeCandidatePath(nativeArgs.path) ?? + normalizeCandidatePath(nativeArgs.file_path) ?? + normalizeCandidatePath(params.path) ?? + normalizeCandidatePath(params.file_path) + + const pathMatches = !toolPath || toolPath === normalizedRelativePath + if (!pathMatches) { + return undefined + } + + switch (block.name) { + case "write_to_file": + return normalizePathLike(nativeArgs.content ?? params.content) + case "apply_diff": + return normalizePathLike(nativeArgs.diff ?? params.diff) + case "edit": + case "search_and_replace": + case "search_replace": + case "edit_file": + return normalizePathLike(nativeArgs.new_string ?? params.new_string) + case "apply_patch": + return normalizePathLike(nativeArgs.patch ?? params.patch) + default: + return undefined + } + } + + private resolveSpecificationReference(intentId: string, intent?: ActiveIntentRecord): string { + const candidateKeys = ["specification_id", "requirement_id", "req_id", "spec_id"] as const + for (const key of candidateKeys) { + const value = intent?.[key] + if (typeof value === "string" && value.trim().length > 0) { + return value.trim() + } + } + return intentId + } + + private async buildTraceRecord( + task: Task, + context: HookPreToolUseContext, + block?: ToolUse, + ): Promise { + const files = [] + for (const relativePath of toUnique(context.touchedPaths)) { + const absolutePath = path.join(task.cwd, relativePath) + + let fileBuffer: Buffer + try { + fileBuffer = await fs.readFile(absolutePath) + } catch { + // Deleted/non-text files still emit a stable empty hash range. + fileBuffer = Buffer.alloc(0) + } + + const contentHash = `sha256:${crypto.createHash("sha256").update(fileBuffer).digest("hex")}` + const text = fileBuffer.toString("utf8") + const lineCount = text.length === 0 ? 0 : text.split(/\r?\n/).length + const payloadText = block ? this.extractToolPayloadForPath(block, relativePath) : undefined + const payloadLineCount = payloadText ? payloadText.split(/\r?\n/).length : 0 + const rangeStart = payloadText ? 1 : lineCount > 0 ? 1 : 0 + const rangeEnd = payloadText ? payloadLineCount : lineCount + const rangeHash = payloadText + ? `sha256:${crypto.createHash("sha256").update(payloadText).digest("hex")}` + : contentHash + let astSummaryHash: string | undefined + try { + const astSummary = await parseSourceCodeDefinitionsForFile(absolutePath) + if (astSummary && astSummary.trim().length > 0) { + astSummaryHash = `sha256:${crypto.createHash("sha256").update(astSummary).digest("hex")}` + } + } catch { + // Best-effort AST extraction for trace linkage. + } + + const specificationRef = this.resolveSpecificationReference(context.intentId!, context.intent) + files.push({ + relative_path: relativePath, + ...(astSummaryHash + ? { + ast_fingerprint: { + parser: "tree-sitter" as const, + summary_hash: astSummaryHash, + }, + } + : {}), + conversations: [ + { + url: task.taskId, + contributor: { + entity_type: "AI" as const, + model_identifier: task.api.getModel().id, + }, + ranges: [ + { + start_line: rangeStart, + end_line: rangeEnd, + content_hash: rangeHash, + }, + ], + related: [{ type: "specification" as const, value: specificationRef }], + }, + ], + }) + } + + return { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + vcs: { + revision_id: await this.getGitRevision(task.cwd), + }, + files, + } + } + + private async getGitRevision(cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd }) + const revision = stdout.trim() + return revision.length > 0 ? revision : "UNKNOWN" + } catch { + return "UNKNOWN" + } + } +} + +export const hookEngine = new HookEngine() + +// hook-smoke diff --git a/src/hooks/IntentContextService.ts b/src/hooks/IntentContextService.ts new file mode 100644 index 00000000000..f760f144ce0 --- /dev/null +++ b/src/hooks/IntentContextService.ts @@ -0,0 +1,94 @@ +import { type ActiveIntentRecord, OrchestrationStore } from "./OrchestrationStore" + +export interface SelectedIntentContext { + id: string + name?: string + status?: string + owned_scope: string[] + constraints: string[] + acceptance_criteria: string[] + recent_history: string[] + related_files: string[] +} + +export interface SelectIntentResult { + found: boolean + context?: SelectedIntentContext + message: string + availableIntentIds: string[] +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) +} + +export class IntentContextService { + constructor(private readonly store: OrchestrationStore) {} + + async selectIntent(intentId: string): Promise { + const normalizedIntentId = intentId.trim() + const intents = await this.store.loadIntents() + const selected = intents.find((intent) => intent.id === normalizedIntentId) + const availableIntentIds = intents.map((intent) => intent.id) + + if (!selected) { + const available = availableIntentIds.length > 0 ? availableIntentIds.join(", ") : "(none)" + return { + found: false, + availableIntentIds, + message: `Intent '${normalizedIntentId}' was not found in .orchestration/active_intents.yaml. Available intent IDs: ${available}.`, + } + } + + const context = this.toContext(selected) + return { + found: true, + context, + availableIntentIds, + message: this.formatContextMessage(context), + } + } + + async markIntentInProgress(intentId: string): Promise { + await this.store.setIntentStatus(intentId, "IN_PROGRESS") + } + + async markIntentCompleted(intentId: string): Promise { + await this.store.setIntentStatus(intentId, "COMPLETED") + } + + private toContext(intent: ActiveIntentRecord): SelectedIntentContext { + return { + id: intent.id, + name: typeof intent.name === "string" && intent.name.trim().length > 0 ? intent.name : undefined, + status: typeof intent.status === "string" ? intent.status : undefined, + owned_scope: normalizeStringArray(intent.owned_scope), + constraints: normalizeStringArray(intent.constraints), + acceptance_criteria: normalizeStringArray(intent.acceptance_criteria), + recent_history: normalizeStringArray(intent.recent_history), + related_files: normalizeStringArray(intent.related_files), + } + } + + private formatContextMessage(context: SelectedIntentContext): string { + const payload = { + id: context.id, + name: context.name ?? null, + status: context.status ?? null, + owned_scope: context.owned_scope, + constraints: context.constraints, + acceptance_criteria: context.acceptance_criteria, + related_files: context.related_files, + recent_history: context.recent_history, + } + + return `Selected active intent context:\n${JSON.stringify(payload, null, 2)}` + } +} diff --git a/src/hooks/OrchestrationStore.ts b/src/hooks/OrchestrationStore.ts new file mode 100644 index 00000000000..bdc1ba20c33 --- /dev/null +++ b/src/hooks/OrchestrationStore.ts @@ -0,0 +1,614 @@ +import fs from "fs/promises" +import path from "path" +import crypto from "crypto" +import * as yaml from "yaml" +import { z } from "zod" + +export type IntentStatus = "IN_PROGRESS" | "PENDING" | "COMPLETED" | "BLOCKED" | string + +export interface ActiveIntentRecord { + id: string + name?: string + status?: IntentStatus + owned_scope: string[] + constraints: string[] + acceptance_criteria: string[] + recent_history: string[] + related_files: string[] + [key: string]: unknown +} + +export interface AgentTraceRange { + start_line: number + end_line: number + content_hash: string +} + +export interface AgentTraceConversation { + url: string + contributor: { + entity_type: "AI" | "HUMAN" + model_identifier: string + } + ranges: AgentTraceRange[] + related: Array<{ + type: "specification" + value: string + }> +} + +export interface AgentTraceFile { + relative_path: string + ast_fingerprint?: { + parser: "tree-sitter" + summary_hash: string + } + conversations: AgentTraceConversation[] +} + +export interface AgentTraceRecord { + id: string + timestamp: string + vcs: { revision_id: string } + files: AgentTraceFile[] + integrity?: { + chain: "sha256" + prev_record_hash: string | null + record_hash: string + } +} + +export interface SidecarDenyMutationRule { + path_glob: string + reason?: string +} + +export interface SidecarPolicy { + version: number + architectural_constraints: string[] + blocked_tools: string[] + deny_mutations: SidecarDenyMutationRule[] +} + +export interface GovernanceEntry { + intent_id?: string + tool_name: string + status: "OK" | "FAILED" | "DENIED" + task_id: string + model_identifier: string + revision_id: string + touched_paths: string[] + sidecar_constraints: string[] +} + +export interface OrchestrationDirectoryContractStatus { + isCompliant: boolean + missingRequiredFiles: string[] + unexpectedEntries: string[] +} + +const sha256HashRegex = /^sha256:[a-f0-9]{64}$/i +const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const traceRangeSchema = z + .object({ + start_line: z.number().int().nonnegative(), + end_line: z.number().int().nonnegative(), + content_hash: z.string().regex(sha256HashRegex), + }) + .refine((value) => value.end_line >= value.start_line, { message: "end_line must be >= start_line" }) + +const traceConversationSchema = z.object({ + url: z.string().min(1), + contributor: z.object({ + entity_type: z.enum(["AI", "HUMAN"]), + model_identifier: z.string().min(1), + }), + ranges: z.array(traceRangeSchema).min(1), + related: z + .array( + z.object({ + type: z.literal("specification"), + value: z.string().min(1), + }), + ) + .min(1), +}) + +const traceFileSchema = z.object({ + relative_path: z.string().min(1), + ast_fingerprint: z + .object({ + parser: z.literal("tree-sitter"), + summary_hash: z.string().regex(sha256HashRegex), + }) + .optional(), + conversations: z.array(traceConversationSchema).min(1), +}) + +const traceRecordSchema = z.object({ + id: z.string().regex(uuidV4Regex), + timestamp: z.string().datetime({ offset: true }), + vcs: z.object({ + revision_id: z.string().min(1), + }), + files: z.array(traceFileSchema).min(1), + integrity: z + .object({ + chain: z.literal("sha256"), + prev_record_hash: z.string().regex(sha256HashRegex).nullable(), + record_hash: z.string().regex(sha256HashRegex), + }) + .optional(), +}) + +const DEFAULT_ACTIVE_INTENTS_YAML = "active_intents: []\n" +const DEFAULT_INTENT_MAP_MD = [ + "# Intent Map", + "", + "Machine-managed mapping between intent IDs and touched code locations.", + "", +].join("\n") +const DEFAULT_SHARED_BRAIN_MD = [ + "# AGENT", + "", + "Machine-managed shared memory for architectural decisions and recurring failures.", + "", +].join("\n") +const DEFAULT_GOVERNANCE_LEDGER_MD = [ + "# Governance Ledger", + "", + "Machine-managed audit of intent, tool use, attribution, and sidecar constraint context.", + "", +].join("\n") +const DEFAULT_SIDECAR_POLICY_YAML = [ + "sidecar:", + " version: 1", + " architectural_constraints:", + ' - "All mutating tool calls must map to an active intent and remain inside owned scope."', + ' - "Architectural invariants are enforced by deterministic hooks, not prompt-only instructions."', + " blocked_tools: []", + " deny_mutations:", + ' - path_glob: ".orchestration/**"', + ' reason: "Orchestration control-plane files are hook-managed."', + "", +].join("\n") + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) +} + +function normalizeStatus(status: unknown): IntentStatus | undefined { + if (typeof status !== "string") { + return undefined + } + + const normalized = status.trim() + return normalized.length > 0 ? normalized : undefined +} + +function normalizeIntentEntry(raw: unknown): ActiveIntentRecord | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null + } + + const entry = raw as Record + const rawId = entry.id ?? entry.intent_id ?? entry.intentId + const id = typeof rawId === "string" ? rawId.trim() : "" + + if (!id) { + return null + } + + const nameCandidate = entry.name ?? entry.title + const name = typeof nameCandidate === "string" && nameCandidate.trim().length > 0 ? nameCandidate.trim() : undefined + const status = normalizeStatus(entry.status) + + const ownedScope = normalizeStringArray(entry.owned_scope ?? entry.ownedScope) + const constraints = normalizeStringArray(entry.constraints) + const acceptanceCriteria = normalizeStringArray(entry.acceptance_criteria ?? entry.acceptanceCriteria) + const recentHistory = normalizeStringArray(entry.recent_history ?? entry.recentHistory) + const relatedFiles = normalizeStringArray(entry.related_files ?? entry.relatedFiles) + + return { + ...entry, + id, + name, + status, + owned_scope: ownedScope, + constraints, + acceptance_criteria: acceptanceCriteria, + recent_history: recentHistory, + related_files: relatedFiles, + } +} + +function canonicalizeForYaml(intent: ActiveIntentRecord): Record { + return { + id: intent.id, + name: intent.name ?? null, + status: intent.status ?? "PENDING", + owned_scope: intent.owned_scope, + constraints: intent.constraints, + acceptance_criteria: intent.acceptance_criteria, + recent_history: intent.recent_history, + related_files: intent.related_files, + } +} + +export class OrchestrationStore { + static readonly ORCHESTRATION_DIR = ".orchestration" + static readonly ACTIVE_INTENTS_FILE = "active_intents.yaml" + static readonly AGENT_TRACE_FILE = "agent_trace.jsonl" + static readonly INTENT_MAP_FILE = "intent_map.md" + static readonly SHARED_BRAIN_FILE = "AGENT.md" + static readonly GOVERNANCE_LEDGER_FILE = "governance_ledger.md" + static readonly SIDECAR_POLICY_FILE = "constraints.sidecar.yaml" + static readonly REQUIRED_ORCHESTRATION_FILES = [ + OrchestrationStore.ACTIVE_INTENTS_FILE, + OrchestrationStore.AGENT_TRACE_FILE, + OrchestrationStore.INTENT_MAP_FILE, + OrchestrationStore.GOVERNANCE_LEDGER_FILE, + OrchestrationStore.SIDECAR_POLICY_FILE, + ] as const + + constructor(private readonly workspacePath: string) {} + + get orchestrationDirPath(): string { + return path.join(this.workspacePath, OrchestrationStore.ORCHESTRATION_DIR) + } + + get activeIntentsPath(): string { + return path.join(this.orchestrationDirPath, OrchestrationStore.ACTIVE_INTENTS_FILE) + } + + get agentTracePath(): string { + return path.join(this.orchestrationDirPath, OrchestrationStore.AGENT_TRACE_FILE) + } + + get intentMapPath(): string { + return path.join(this.orchestrationDirPath, OrchestrationStore.INTENT_MAP_FILE) + } + + get sharedBrainPath(): string { + return path.join(this.workspacePath, OrchestrationStore.SHARED_BRAIN_FILE) + } + + get governanceLedgerPath(): string { + return path.join(this.orchestrationDirPath, OrchestrationStore.GOVERNANCE_LEDGER_FILE) + } + + get sidecarPolicyPath(): string { + return path.join(this.orchestrationDirPath, OrchestrationStore.SIDECAR_POLICY_FILE) + } + + async ensureInitialized(): Promise { + await fs.mkdir(this.orchestrationDirPath, { recursive: true }) + await this.ensureFile(this.activeIntentsPath, DEFAULT_ACTIVE_INTENTS_YAML) + await this.ensureFile(this.agentTracePath, "") + await this.ensureFile(this.intentMapPath, DEFAULT_INTENT_MAP_MD) + await this.ensureFile(this.sharedBrainPath, DEFAULT_SHARED_BRAIN_MD) + await this.ensureFile(this.governanceLedgerPath, DEFAULT_GOVERNANCE_LEDGER_MD) + await this.ensureFile(this.sidecarPolicyPath, DEFAULT_SIDECAR_POLICY_YAML) + } + + async loadIntents(): Promise { + await this.ensureInitialized() + const raw = await fs.readFile(this.activeIntentsPath, "utf8") + const parsed = yaml.parse(raw) as unknown + return this.normalizeIntents(parsed) + } + + async findIntentById(intentId: string): Promise { + const normalizedIntentId = intentId.trim() + if (!normalizedIntentId) { + return undefined + } + + const intents = await this.loadIntents() + return intents.find((intent) => intent.id === normalizedIntentId) + } + + async upsertIntent(intent: ActiveIntentRecord): Promise { + const intents = await this.loadIntents() + const next = [...intents] + const index = next.findIndex((candidate) => candidate.id === intent.id) + if (index >= 0) { + next[index] = intent + } else { + next.push(intent) + } + + await this.saveIntents(next) + } + + async setIntentStatus(intentId: string, status: IntentStatus): Promise { + const intents = await this.loadIntents() + const index = intents.findIndex((intent) => intent.id === intentId) + if (index < 0) { + return + } + + intents[index] = { + ...intents[index], + status, + } + + await this.saveIntents(intents) + } + + async appendRecentHistory(intentId: string, event: string): Promise { + const cleanedEvent = event.trim() + if (!cleanedEvent) { + return + } + + const intents = await this.loadIntents() + const index = intents.findIndex((intent) => intent.id === intentId) + if (index < 0) { + return + } + + const previous = intents[index].recent_history ?? [] + const nextHistory = [cleanedEvent, ...previous].slice(0, 20) + intents[index] = { + ...intents[index], + recent_history: nextHistory, + } + + await this.saveIntents(intents) + } + + async appendTraceRecord(record: AgentTraceRecord): Promise { + await this.ensureInitialized() + this.validateTraceRecord(record) + const prevHash = await this.getLastTraceRecordHash() + const canonicalPayload = this.getTraceCanonicalPayload(record, prevHash) + const recordHash = this.computeSha256(canonicalPayload) + const recordWithIntegrity: AgentTraceRecord = { + ...record, + integrity: { + chain: "sha256", + prev_record_hash: prevHash, + record_hash: recordHash, + }, + } + this.validateTraceRecord(recordWithIntegrity) + await fs.appendFile(this.agentTracePath, `${JSON.stringify(recordWithIntegrity)}\n`, "utf8") + } + + async appendIntentMapEntry( + intent: ActiveIntentRecord, + filePaths: string[], + astFingerprints?: Record, + ): Promise { + await this.ensureInitialized() + + const normalizedPaths = Array.from( + new Set(filePaths.map((filePath) => filePath.trim().replace(/\\/g, "/")).filter(Boolean)), + ) + if (normalizedPaths.length === 0) { + return + } + + const header = `## ${intent.id}${intent.name ? ` - ${intent.name}` : ""} (${new Date().toISOString()})` + const lines = [ + "", + header, + ...normalizedPaths.map((filePath) => { + const astFingerprint = astFingerprints?.[filePath] + return astFingerprint + ? `- \`${filePath}\` (ast_fingerprint: \`${astFingerprint}\`)` + : `- \`${filePath}\`` + }), + "", + ] + await fs.appendFile(this.intentMapPath, lines.join("\n"), "utf8") + } + + async appendSharedBrainEntry(entry: string): Promise { + const cleanedEntry = entry.trim() + if (!cleanedEntry) { + return + } + + await this.ensureInitialized() + const line = `- ${new Date().toISOString()}: ${cleanedEntry}\n` + await fs.appendFile(this.sharedBrainPath, line, "utf8") + } + + async loadSidecarPolicy(): Promise { + await this.ensureInitialized() + const raw = await fs.readFile(this.sidecarPolicyPath, "utf8") + const parsed = yaml.parse(raw) as unknown + const root = + typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : {} + const sidecarRaw = + typeof root.sidecar === "object" && root.sidecar !== null && !Array.isArray(root.sidecar) + ? (root.sidecar as Record) + : root + + const version = + typeof sidecarRaw.version === "number" && Number.isFinite(sidecarRaw.version) ? sidecarRaw.version : 1 + + const blockedTools = normalizeStringArray(sidecarRaw.blocked_tools ?? sidecarRaw.blockedTools) + const architecturalConstraints = normalizeStringArray( + sidecarRaw.architectural_constraints ?? sidecarRaw.architecturalConstraints, + ) + const denyMutationsRaw = Array.isArray(sidecarRaw.deny_mutations) + ? sidecarRaw.deny_mutations + : Array.isArray(sidecarRaw.denyMutations) + ? sidecarRaw.denyMutations + : [] + const denyMutationsMapped = denyMutationsRaw.map((entry): SidecarDenyMutationRule | null => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null + } + const record = entry as Record + const pathGlob = typeof record.path_glob === "string" ? record.path_glob.trim() : "" + if (!pathGlob) { + return null + } + const reason = + typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason : undefined + return { path_glob: pathGlob, reason } + }) + const denyMutations = denyMutationsMapped.filter((entry): entry is SidecarDenyMutationRule => entry !== null) + + return { + version, + architectural_constraints: architecturalConstraints, + blocked_tools: blockedTools, + deny_mutations: denyMutations, + } + } + + async appendGovernanceEntry(entry: GovernanceEntry): Promise { + await this.ensureInitialized() + const touched = + entry.touched_paths.length > 0 ? entry.touched_paths.map((p) => `\`${p}\``).join(", ") : "(none)" + const constraints = + entry.sidecar_constraints.length > 0 ? entry.sidecar_constraints.map((c) => `"${c}"`).join(" | ") : "(none)" + const line = [ + `- ${new Date().toISOString()} | status=${entry.status} | tool=${entry.tool_name} | intent=${entry.intent_id ?? "none"} | task=${entry.task_id} | model=${entry.model_identifier} | rev=${entry.revision_id}`, + ` touched_paths=${touched}`, + ` sidecar_constraints=${constraints}`, + ].join("\n") + await fs.appendFile(this.governanceLedgerPath, `${line}\n`, "utf8") + } + + async getDirectoryContractStatus(): Promise { + await this.ensureInitialized() + const requiredFiles = new Set(OrchestrationStore.REQUIRED_ORCHESTRATION_FILES) + + const entries = await fs.readdir(this.orchestrationDirPath, { withFileTypes: true }) + const unexpectedEntries = entries + .filter((entry) => !requiredFiles.has(entry.name) || !entry.isFile()) + .map((entry) => entry.name) + .sort() + + const missingRequiredFiles: string[] = [] + for (const fileName of requiredFiles) { + const filePath = path.join(this.orchestrationDirPath, fileName) + try { + const stat = await fs.stat(filePath) + if (!stat.isFile()) { + missingRequiredFiles.push(fileName) + } + } catch { + missingRequiredFiles.push(fileName) + } + } + + return { + isCompliant: unexpectedEntries.length === 0 && missingRequiredFiles.length === 0, + missingRequiredFiles, + unexpectedEntries, + } + } + + private async saveIntents(intents: ActiveIntentRecord[]): Promise { + await this.ensureInitialized() + const root = { + active_intents: intents.map((intent) => canonicalizeForYaml(intent)), + } + const serialized = yaml.stringify(root, { lineWidth: 0 }) + await fs.writeFile(this.activeIntentsPath, serialized, "utf8") + } + + private normalizeIntents(parsed: unknown): ActiveIntentRecord[] { + if (!parsed) { + return [] + } + + if (Array.isArray(parsed)) { + return parsed + .map((entry) => normalizeIntentEntry(entry)) + .filter((entry): entry is ActiveIntentRecord => entry !== null) + } + + if (typeof parsed === "object" && parsed !== null) { + const root = parsed as Record + const list = root.active_intents ?? root.intents + + if (Array.isArray(list)) { + return list + .map((entry) => normalizeIntentEntry(entry)) + .filter((entry): entry is ActiveIntentRecord => entry !== null) + } + + return Object.entries(root) + .map(([id, entry]) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null + } + return normalizeIntentEntry({ id, ...(entry as Record) }) + }) + .filter((entry): entry is ActiveIntentRecord => entry !== null) + } + + return [] + } + + private async ensureFile(filePath: string, initialContent: string): Promise { + try { + await fs.access(filePath) + } catch { + await fs.writeFile(filePath, initialContent, "utf8") + } + } + + private validateTraceRecord(record: AgentTraceRecord): void { + const parsed = traceRecordSchema.safeParse(record) + if (!parsed.success) { + const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") + throw new Error(`Invalid AgentTraceRecord schema: ${detail}`) + } + } + + private computeSha256(payload: string): string { + return `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}` + } + + private getTraceCanonicalPayload(record: AgentTraceRecord, prevHash: string | null): string { + const payload = { + id: record.id, + timestamp: record.timestamp, + vcs: record.vcs, + files: record.files, + prev_record_hash: prevHash, + } + return JSON.stringify(payload) + } + + private async getLastTraceRecordHash(): Promise { + await this.ensureInitialized() + const raw = await fs.readFile(this.agentTracePath, "utf8") + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (lines.length === 0) { + return null + } + const lastLine = lines[lines.length - 1] + try { + const parsed = JSON.parse(lastLine) as AgentTraceRecord + if (parsed.integrity?.record_hash && sha256HashRegex.test(parsed.integrity.record_hash)) { + return parsed.integrity.record_hash + } + } catch { + // Fall through to hash the raw line. + } + return this.computeSha256(lastLine) + } +} diff --git a/src/hooks/__tests__/HookEngine.spec.ts b/src/hooks/__tests__/HookEngine.spec.ts new file mode 100644 index 00000000000..fc7ca2fbbc7 --- /dev/null +++ b/src/hooks/__tests__/HookEngine.spec.ts @@ -0,0 +1,97 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, expect, it, vi } from "vitest" + +import { HookEngine } from "../HookEngine" +import { Task } from "../../core/task/Task" +import type { ToolUse } from "../../shared/tools" + +function createMutatingToolBlock(): ToolUse { + return { + type: "tool_use", + name: "write_to_file", + params: { path: "src/a.ts", content: "x" }, + partial: false, + nativeArgs: { path: "src/a.ts", content: "x" }, + } +} + +describe("HookEngine two-stage + HITL gating", () => { + it("denies mutating tools when intent checkout is not authorized for the turn", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-hook-stage-")) + const orchestrationDir = path.join(workspacePath, ".orchestration") + await fs.mkdir(orchestrationDir, { recursive: true }) + await fs.writeFile( + path.join(orchestrationDir, "active_intents.yaml"), + [ + "active_intents:", + " - id: INT-1", + " status: IN_PROGRESS", + ' owned_scope: ["src/**"]', + " constraints: []", + " acceptance_criteria: []", + " recent_history: []", + " related_files: []", + "", + ].join("\n"), + "utf8", + ) + + const task = { + cwd: workspacePath, + workspacePath, + taskId: "task-1", + activeIntentId: "INT-1", + didToolFailInCurrentTurn: false, + api: { getModel: () => ({ id: "gpt-test" }) }, + getIntentCheckoutStage: () => "checkout_required", + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } as unknown as Task + + const engine = new HookEngine() + const result = await engine.preToolUse(task, createMutatingToolBlock()) + + expect(result.allowExecution).toBe(false) + expect(result.errorMessage).toContain("intent checkout required") + }) + + it("allows mutating tools after checkout when HITL approves", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-hook-hitl-")) + const orchestrationDir = path.join(workspacePath, ".orchestration") + await fs.mkdir(orchestrationDir, { recursive: true }) + await fs.writeFile( + path.join(orchestrationDir, "active_intents.yaml"), + [ + "active_intents:", + " - id: INT-2", + " status: IN_PROGRESS", + ' owned_scope: ["src/**"]', + " constraints: []", + " acceptance_criteria: []", + " recent_history: []", + " related_files: []", + "", + ].join("\n"), + "utf8", + ) + + const ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + const task = { + cwd: workspacePath, + workspacePath, + taskId: "task-2", + activeIntentId: "INT-2", + didToolFailInCurrentTurn: false, + api: { getModel: () => ({ id: "gpt-test" }) }, + getIntentCheckoutStage: () => "execution_authorized", + ask, + } as unknown as Task + + const engine = new HookEngine() + const result = await engine.preToolUse(task, createMutatingToolBlock()) + + expect(result.allowExecution).toBe(true) + expect(ask).toHaveBeenCalled() + }) +}) diff --git a/src/hooks/__tests__/OrchestrationStore.spec.ts b/src/hooks/__tests__/OrchestrationStore.spec.ts new file mode 100644 index 00000000000..c54d1790e53 --- /dev/null +++ b/src/hooks/__tests__/OrchestrationStore.spec.ts @@ -0,0 +1,130 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, expect, it } from "vitest" + +import { OrchestrationStore } from "../OrchestrationStore" + +describe("OrchestrationStore", () => { + it("creates default sidecar policy and governance ledger", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-orch-store-")) + const store = new OrchestrationStore(workspacePath) + + await store.ensureInitialized() + const sidecar = await store.loadSidecarPolicy() + const governanceLedger = await fs.readFile( + path.join(workspacePath, ".orchestration", "governance_ledger.md"), + "utf8", + ) + + expect(sidecar.version).toBe(1) + expect(sidecar.architectural_constraints.length).toBeGreaterThan(0) + expect(sidecar.deny_mutations.some((rule) => rule.path_glob === ".orchestration/**")).toBe(true) + expect(governanceLedger).toContain("# Governance Ledger") + }) + + it("appends governance entries with attribution details", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-orch-governance-")) + const store = new OrchestrationStore(workspacePath) + + await store.appendGovernanceEntry({ + intent_id: "intent-123", + tool_name: "write_to_file", + status: "OK", + task_id: "task-1", + model_identifier: "gpt-test", + revision_id: "rev-abc", + touched_paths: ["src/a.ts"], + sidecar_constraints: ["No cross-module writes"], + }) + + const ledger = await fs.readFile(path.join(workspacePath, ".orchestration", "governance_ledger.md"), "utf8") + expect(ledger).toContain("status=OK") + expect(ledger).toContain("intent=intent-123") + expect(ledger).toContain("tool=write_to_file") + expect(ledger).toContain("`src/a.ts`") + expect(ledger).toContain("No cross-module writes") + }) + + it("reports orchestration directory contract drift", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-orch-contract-")) + const store = new OrchestrationStore(workspacePath) + await store.ensureInitialized() + + const orchestrationDir = path.join(workspacePath, ".orchestration") + await fs.rm(path.join(orchestrationDir, "intent_map.md")) + await fs.mkdir(path.join(orchestrationDir, "intent_map.md")) + await fs.writeFile(path.join(orchestrationDir, "rogue.txt"), "rogue", "utf8") + await fs.mkdir(path.join(orchestrationDir, "nested"), { recursive: true }) + + const status = await store.getDirectoryContractStatus() + expect(status.isCompliant).toBe(false) + expect(status.missingRequiredFiles).toContain("intent_map.md") + expect(status.unexpectedEntries).toContain("rogue.txt") + expect(status.unexpectedEntries).toContain("nested") + }) + + it("enforces trace schema and appends integrity hash chain", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-orch-trace-")) + const store = new OrchestrationStore(workspacePath) + + const baseRecord = { + id: "550e8400-e29b-41d4-a716-446655440000", + timestamp: new Date().toISOString(), + vcs: { revision_id: "abc123" }, + files: [ + { + relative_path: "src/auth/middleware.ts", + conversations: [ + { + url: "session-1", + contributor: { entity_type: "AI" as const, model_identifier: "claude-3-5-sonnet" }, + ranges: [ + { + start_line: 15, + end_line: 45, + content_hash: + "sha256:a8f5f167f44f4964e6c998dee827110ca8f5f167f44f4964e6c998dee827110c", + }, + ], + related: [{ type: "specification" as const, value: "REQ-001" }], + }, + ], + }, + ], + } + + await store.appendTraceRecord(baseRecord) + await store.appendTraceRecord({ + ...baseRecord, + id: "550e8400-e29b-41d4-a716-446655440001", + timestamp: new Date(Date.now() + 1000).toISOString(), + }) + + const lines = (await fs.readFile(path.join(workspacePath, ".orchestration", "agent_trace.jsonl"), "utf8")) + .split(/\r?\n/) + .filter(Boolean) + expect(lines).toHaveLength(2) + const first = JSON.parse(lines[0]) + const second = JSON.parse(lines[1]) + expect(first.integrity.chain).toBe("sha256") + expect(first.integrity.prev_record_hash).toBeNull() + expect(first.integrity.record_hash).toMatch(/^sha256:[a-f0-9]{64}$/) + expect(second.integrity.prev_record_hash).toBe(first.integrity.record_hash) + expect(second.integrity.record_hash).toMatch(/^sha256:[a-f0-9]{64}$/) + }) + + it("rejects trace records that violate strict schema", async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-orch-trace-invalid-")) + const store = new OrchestrationStore(workspacePath) + + const invalidRecord = { + id: "not-a-uuid", + timestamp: "invalid-timestamp", + vcs: { revision_id: "" }, + files: [], + } as any + + await expect(store.appendTraceRecord(invalidRecord)).rejects.toThrow("Invalid AgentTraceRecord schema") + }) +}) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba693611..67e8e91e8bf 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -40,6 +40,7 @@ export const toolParamNames = [ "uri", "question", "result", + "intent_id", "diff", "mode_slug", "reason", @@ -93,6 +94,7 @@ export type NativeToolArgs = { read_file: import("@roo-code/types").ReadFileToolParams read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } + select_active_intent: { intent_id: string } execute_command: { command: string; cwd?: string } apply_diff: { path: string; diff: string } edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } @@ -232,6 +234,11 @@ export interface AttemptCompletionToolUse extends ToolUse<"attempt_completion"> params: Partial, "result">> } +export interface SelectActiveIntentToolUse extends ToolUse<"select_active_intent"> { + name: "select_active_intent" + params: Partial, "intent_id">> +} + export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { name: "switch_mode" params: Partial, "mode_slug" | "reason">> @@ -281,6 +288,7 @@ export const TOOL_DISPLAY_NAMES: Record = { access_mcp_resource: "access mcp resources", ask_followup_question: "ask questions", attempt_completion: "complete tasks", + select_active_intent: "select active intent", switch_mode: "switch modes", new_task: "create new task", codebase_search: "codebase search", @@ -316,6 +324,7 @@ export const TOOL_GROUPS: Record = { export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "ask_followup_question", "attempt_completion", + "select_active_intent", "switch_mode", "new_task", "update_todo_list", diff --git a/src/utils/chatTrace.ts b/src/utils/chatTrace.ts new file mode 100644 index 00000000000..43801dc543c --- /dev/null +++ b/src/utils/chatTrace.ts @@ -0,0 +1,31 @@ +import * as fs from "fs/promises" +import * as path from "path" + +/** + * Append a compact trace line to /logs/chat-trace.log. Swallows errors so it + * never interferes with runtime behavior. + */ +export async function appendChatTrace(cwd: string | undefined, line: string): Promise { + try { + const base = cwd && cwd.length ? cwd : process.cwd() + const dir = path.join(base, "logs") + await fs.mkdir(dir, { recursive: true }) + const file = path.join(dir, "chat-trace.log") + const ts = new Date().toISOString() + const entry = `${ts} ${line}\n` + await fs.appendFile(file, entry, "utf8") + } catch (err) { + // never throw from tracing + console.warn("[chatTrace] failed to write trace:", err) + } +} + +/** + * Compact a string for single-line logging (truncate and escape newlines). + */ +export function compact(input?: string | null, max = 180): string { + if (!input) return "" + const s = String(input).replace(/\s+/g, " ").trim() + if (s.length <= max) return s + return s.slice(0, max - 1) + "…" +}