mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
Merge 3db3b0112a into 7adbfec2a4
This commit is contained in:
commit
10b5afb71a
58 changed files with 1813 additions and 13 deletions
0
.github/copilot-instructions.md
vendored
Normal file
0
.github/copilot-instructions.md
vendored
Normal file
31
.orchestration/active_intents.yaml
Normal file
31
.orchestration/active_intents.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
active_intents:
|
||||
- id: "INT-001"
|
||||
name: "Weather API Implementation"
|
||||
status: "IN_PROGRESS"
|
||||
owned_scope:
|
||||
- "src/weather/**"
|
||||
- "*.js"
|
||||
- "*.ts"
|
||||
constraints:
|
||||
- "Use async/await for all async operations"
|
||||
- "Include error handling with try/catch"
|
||||
- "Return JSON format responses"
|
||||
- "Add input validation"
|
||||
acceptance_criteria:
|
||||
- "Unit tests pass with >80% coverage"
|
||||
- "API returns correct weather data"
|
||||
- "Error cases handled gracefully"
|
||||
|
||||
- id: "INT-002"
|
||||
name: "Authentication Middleware"
|
||||
status: "PLANNED"
|
||||
owned_scope:
|
||||
- "src/auth/**"
|
||||
- "src/middleware/auth.ts"
|
||||
constraints:
|
||||
- "Use JWT tokens"
|
||||
- "Implement rate limiting"
|
||||
- "Log all auth attempts"
|
||||
acceptance_criteria:
|
||||
- "Auth tests pass"
|
||||
- "Security audit passed"
|
||||
2
.orchestration/agent_trace.jsonl
Normal file
2
.orchestration/agent_trace.jsonl
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"timestamp":"2026-02-21T20:41:12.120Z","intent_id":"INT-001","file_path":"src/core/tools/WriteToFileTool.ts","content_sha256":"5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9","semantic_change":"EVOLUTION","tool":"write_to_file"}
|
||||
{"timestamp":"2026-02-21T20:52:45.918Z","intent_id":"INT-001","file_path":"src/core/assistant-message/presentAssistantMessage.ts","content_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","semantic_change":"REFACTOR","tool":"apply_patch"}
|
||||
29
.orchestration/intent_map.md
Normal file
29
.orchestration/intent_map.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Intent Map
|
||||
|
||||
## INT-001: Weather API Implementation
|
||||
|
||||
- **Status:** IN_PROGRESS
|
||||
- **Files:**
|
||||
- `src/weather/api.js` - Main API endpoints
|
||||
- `src/weather/service.js` - Weather service logic
|
||||
- `src/weather/utils.js` - Helper functions
|
||||
- **AST Nodes:**
|
||||
- `WeatherService` class
|
||||
- `getWeatherData()` function
|
||||
- `formatWeatherResponse()` function
|
||||
- **Dependencies:** OpenWeatherMap API
|
||||
- **Last Updated:** 2026-02-21
|
||||
|
||||
## INT-002: Authentication Middleware
|
||||
|
||||
- **Status:** PLANNED
|
||||
- **Files:**
|
||||
- `src/auth/jwt.js` - JWT handling
|
||||
- `src/middleware/auth.js` - Auth middleware
|
||||
- `src/auth/rate-limit.js` - Rate limiting
|
||||
- **AST Nodes:**
|
||||
- `authenticateToken()` middleware
|
||||
- `generateToken()` function
|
||||
- `RateLimiter` class
|
||||
- **Dependencies:** jsonwebtoken, express-rate-limit
|
||||
- **Last Updated:** 2026-02-21
|
||||
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
|
|
@ -9,6 +9,7 @@
|
|||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"stopOnEntry": false,
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}/src"],
|
||||
"sourceMaps": true,
|
||||
|
|
|
|||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"servers": {
|
||||
"tenxfeedbackanalytics": {
|
||||
"url": "https://mcppulse.10academy.org/proxy",
|
||||
"type": "http",
|
||||
"headers": {
|
||||
"X-Device": "windows",
|
||||
"X-Coding-Tool": "vscode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
90
ARCHITECTURE_NOTES.md
Normal file
90
ARCHITECTURE_NOTES.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Architecture Notes: Master Thinker Edition
|
||||
|
||||
## 1. Data Flow Map (Request-to-Execution)
|
||||
|
||||
1. User Input: Received via the React Webview.
|
||||
2. Context Assembly: `ClineProvider.ts` calls `src/core/task/Task.ts` to generate the System Prompt.
|
||||
3. Decision: The LLM selects a tool. `presentAssistantMessage.ts` orchestrates the tool call.
|
||||
4. Execution: `WriteToFileTool.ts` executes the `handle()` method to modify the file system.
|
||||
|
||||
## 2. Governance Interception Points
|
||||
|
||||
- **Pre-Hook (Phase 1/2):** Located in `WriteToFileTool.ts` inside `handle()`. This will block execution if `.orchestration/active_intents.yaml` does not have an `in-progress` status.
|
||||
- **Post-Hook (Phase 3):** Located at the end of `handle()` after a successful write. This will trigger the `agent_trace.jsonl` logger to hash the new content.
|
||||
|
||||
## 3. Intent-Code Gap Analysis
|
||||
|
||||
Standard Git tracks "What" changed but lacks the "Why." By using a sidecar orchestration layer, we map every Abstract Syntax Tree (AST) change to a specific Requirement ID. This prevents "Context Rot" where agents lose track of architectural constraints during long-running tasks.
|
||||
|
||||
# Architectural Design Report
|
||||
|
||||
## 1. The Intent-First Protocol (Two-Stage State Machine)
|
||||
|
||||
The core of this implementation is a move away from "Vibe Coding" towards a governed, stateful interaction. I have architected a Two-Stage State Machine for every user request:
|
||||
|
||||
**Stage 1: The Reasoning Intercept (The Handshake):** The agent is no longer permitted to generate code immediately. It must first analyze the request, identify a valid intent_id from the governance sidecar, and call the select_active_intent tool.
|
||||
+1
|
||||
|
||||
**Stage 2: Contextualized Action:** Only after the "Handshake" is successful and the context is injected can the agent proceed to use destructive tools like write_to_file or execute_command.
|
||||
+1
|
||||
|
||||
## 2. The Deterministic Hook (Gatekeeper Architecture)
|
||||
|
||||
To ensure compliance, I implemented a Deterministic Hook System that acts as a strict middleware boundary:
|
||||
+1
|
||||
|
||||
Pre-Hook Implementation: In WriteToFileTool.ts and ExecuteCommandTool.ts, I injected a gatekeeper check at the start of the handle method.
|
||||
|
||||
Verification Logic: This hook verifies the presence of a global active intent flag. If the agent attempts a file modification without a validated "checkout," the hook blocks execution and returns a formal governance error: "You must cite a valid active Intent ID".
|
||||
|
||||
Fail-Safe: This ensures that the architecture enforces the rules, rather than relying on the LLM's "best effort" to follow instructions.
|
||||
|
||||
## 3. Context Engineering (Dynamic Injection vs. Context Rot)
|
||||
|
||||
Traditional AI IDEs suffer from "Context Rot" by dumping entire file trees into the prompt. This implementation solves this via Dynamic Context Injection:
|
||||
+1
|
||||
|
||||
**Sidecar Pattern:** All architectural constraints and business intents are stored in .orchestration/active_intents.yaml.
|
||||
+1
|
||||
|
||||
**On-Demand Context:** When select_active_intent is called, the system reads the YAML and constructs a targeted <intent_context> XML block.
|
||||
|
||||
**Traceability:** This ensures the agent only operates within its "owned_scope" and respects the "acceptance_criteria" defined in the sidecar, maintaining a high signal-to-noise ratio in the context window.
|
||||
|
||||
## 5. Diagrams and Schemas (Required for Interim Submission)
|
||||
|
||||
### A. The Two-Stage Handshake (Sequence Diagram)
|
||||
|
||||
This diagram illustrates how the Hook Engine intercepts the LLM's request to ensure intent-validation before execution.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant LLM as Roo Code Agent
|
||||
participant Hook as Hook Engine (Middleware)
|
||||
participant Sidecar as .orchestration/active_intents.yaml
|
||||
|
||||
User->>LLM: "Refactor Auth Logic"
|
||||
Note over LLM: Agent is blocked from writing code
|
||||
LLM->>Hook: call select_active_intent(INT-001)
|
||||
Hook->>Sidecar: Validate Intent ID & Status
|
||||
Sidecar-->>Hook: Return Scope & Constraints
|
||||
Hook-->>LLM: Inject <intent_context> into prompt
|
||||
Note over LLM: Agent now has authorization
|
||||
LLM->>User: "I have loaded intent INT-001. Proceeding..."
|
||||
```
|
||||
|
||||
### B. Intent Context Schema
|
||||
|
||||
```xml
|
||||
<intent_context>
|
||||
<intent_id>INT-001</intent_id>
|
||||
<status>IN PROGRESS</status>
|
||||
<owned_scope>
|
||||
<file>src/auth/\*\*</file>
|
||||
<file>src/middleware/jwt.ts</file>
|
||||
</owned_scope>
|
||||
<constraints> - Must not use external auth providers - Maintain backward compatibility
|
||||
</constraints>
|
||||
</intent_context>
|
||||
```
|
||||
40
SUBMISSION_README.md
Normal file
40
SUBMISSION_README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Roo Code Master Thinker - Week 1 Final Submission
|
||||
|
||||
## Project Overview
|
||||
|
||||
A governed AI-native IDE with intent-code traceability, human-in-the-loop security, and concurrent agent safety. This implementation transforms the Roo Code extension from a simple chatbot into a governed orchestration system where every action requires verified intent.
|
||||
|
||||
## Implemented Phases
|
||||
|
||||
### ✅ Phase 0: Architecture Mapping
|
||||
|
||||
- Mapped tool execution flow in `presentAssistantMessage.ts`
|
||||
- Located prompt builder in `system.ts`
|
||||
- Identified webview communication in `ClineProvider.ts`
|
||||
|
||||
### ✅ Phase 1: The Handshake
|
||||
|
||||
- `select_active_intent` tool for intent selection
|
||||
- `IntentContextLoader` reads YAML and returns XML context
|
||||
- System prompt enforces intent-first protocol
|
||||
- Gatekeeper blocks writes without valid intent
|
||||
|
||||
### ✅ Phase 2: Hook Middleware
|
||||
|
||||
- Command classification (SAFE vs DESTRUCTIVE)
|
||||
- HITL authorization dialogs for destructive commands
|
||||
- Scope enforcement against intent's `owned_scope`
|
||||
- Autonomous recovery with structured error responses
|
||||
|
||||
### ✅ Phase 3: Traceability
|
||||
|
||||
- SHA-256 content hashing for spatial independence
|
||||
- Semantic classification (REFACTOR vs EVOLUTION)
|
||||
- JSONL trace recording to `agent_trace.jsonl`
|
||||
- Links intent IDs to content hashes
|
||||
|
||||
### ✅ Phase 4: Concurrency
|
||||
|
||||
- Optimistic locking with pre-write hash capture
|
||||
- Stale file detection and blocking
|
||||
- Clear conflict resolution with re-read guidance
|
||||
321
docs/submission-report.md
Normal file
321
docs/submission-report.md
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
# Master Thinker Orchestration Architecture Report
|
||||
|
||||
## Submission Draft (Markdown → PDF)
|
||||
|
||||
**Project:** Roo Code Master Thinker
|
||||
**Date:** February 21, 2026
|
||||
**Prepared by:** Yakob
|
||||
|
||||
---
|
||||
|
||||
## 1) Architecture Overview
|
||||
|
||||
This implementation evolves the extension into an intent-governed orchestration system across four phases:
|
||||
|
||||
1. **Phase 1 — Handshake:** force intent selection before destructive actions.
|
||||
2. **Phase 2 — Hook Middleware:** add classification + human authorization + scope checks in the execution path.
|
||||
3. **Phase 3 — Traceability:** persist intent-linked file-change traces with deterministic content hashing.
|
||||
4. **Phase 4 — Concurrency:** prevent stale writes using optimistic locking and conflict feedback.
|
||||
|
||||
The guiding principle is: **no destructive write without validated intent context**, and **no untraceable mutation**.
|
||||
|
||||
---
|
||||
|
||||
### Diagram A — Two-Stage Handshake
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant L as LLM Agent
|
||||
participant T as select_active_intent Tool
|
||||
participant Y as active_intents.yaml
|
||||
participant W as Write Tool
|
||||
|
||||
U->>L: "Implement change X"
|
||||
L->>T: select_active_intent(intent_id)
|
||||
T->>Y: load intent context
|
||||
Y-->>T: constraints + scope (+ status)
|
||||
T-->>L: intent_context XML
|
||||
L->>W: write_to_file/apply_patch
|
||||
Note over W: Allowed only after valid active intent context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Diagram B — Hook Middleware Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Tool call emitted] --> B{Command Classification}
|
||||
B -->|SAFE| C[Continue]
|
||||
B -->|DESTRUCTIVE| D[HITL Authorization]
|
||||
D -->|Denied| X[Reject tool_result + stop]
|
||||
D -->|Approved| E[Intent Context Validation]
|
||||
E -->|Invalid/Missing| X
|
||||
E -->|Valid| F[Scope Enforcement]
|
||||
F -->|Out of scope| X
|
||||
F -->|In scope| G[Execute tool]
|
||||
G --> H[Return tool_result]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Diagram C — Traceability Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Successful file write] --> B[Read final content]
|
||||
B --> C[SHA-256 hash]
|
||||
C --> D[Semantic classifier: REFACTOR/EVOLUTION]
|
||||
D --> E[Build trace entry]
|
||||
E --> F[Append JSONL line to .orchestration/agent_trace.jsonl]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Diagram D — Concurrency Control
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Before write: capture initial hash] --> B[User approval complete]
|
||||
B --> C[Recompute current file hash]
|
||||
C --> D{Hash unchanged?}
|
||||
D -->|Yes| E[Proceed write]
|
||||
D -->|No| F[Raise StaleFileError]
|
||||
F --> G[Return error tool_result]
|
||||
G --> H[Force re-read before retry]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) Phase 0: Architecture Notes (Archaeological Dig)
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **Tool execution choke-point:** assistant tool execution is centralized in [src/core/assistant-message/presentAssistantMessage.ts](../src/core/assistant-message/presentAssistantMessage.ts).
|
||||
- **Prompt assembly and request orchestration:** handled in [src/core/task/Task.ts](../src/core/task/Task.ts), including pre-hook context injection flow.
|
||||
- **Intent sidecar source-of-truth:** [.orchestration/active_intents.yaml](../.orchestration/active_intents.yaml).
|
||||
- **Webview runtime orchestration:** [src/core/webview/ClineProvider.ts](../src/core/webview/ClineProvider.ts).
|
||||
- **Extension activation/lifecycle root:** [src/extension.ts](../src/extension.ts).
|
||||
|
||||
### Tool Execution Locations
|
||||
|
||||
- Intent selection tool implementation: [src/core/tools/SelectActiveIntentTool.ts](../src/core/tools/SelectActiveIntentTool.ts)
|
||||
- Write path and post-write operations: [src/core/tools/WriteToFileTool.ts](../src/core/tools/WriteToFileTool.ts)
|
||||
- Patch path: [src/core/tools/ApplyPatchTool.ts](../src/core/tools/ApplyPatchTool.ts)
|
||||
|
||||
### Prompt Builder Location
|
||||
|
||||
- System prompt composition path: [src/core/prompts/system.ts](../src/core/prompts/system.ts)
|
||||
- Runtime message and request chain: [src/core/task/Task.ts](../src/core/task/Task.ts)
|
||||
|
||||
### Webview Communication
|
||||
|
||||
- Provider/webview messaging bridge: [src/core/webview/ClineProvider.ts](../src/core/webview/ClineProvider.ts)
|
||||
- Activation + provider registration: [src/extension.ts](../src/extension.ts)
|
||||
|
||||
---
|
||||
|
||||
## 3) Phase 1: The Handshake
|
||||
|
||||
### Components Implemented
|
||||
|
||||
- **Intent Tool:** [src/core/tools/SelectActiveIntentTool.ts](../src/core/tools/SelectActiveIntentTool.ts)
|
||||
|
||||
- Resolves intent by ID from sidecar YAML.
|
||||
- Stores active intent in provider state.
|
||||
- Returns context payload for subsequent orchestration.
|
||||
|
||||
- **Intent Loader:** [src/core/intent/IntentContextLoader.ts](../src/core/intent/IntentContextLoader.ts)
|
||||
|
||||
- Parses [.orchestration/active_intents.yaml](../.orchestration/active_intents.yaml).
|
||||
- Normalizes and resolves intent objects.
|
||||
- Produces XML fragments for controlled injection.
|
||||
|
||||
- **System Prompt Integration:** [src/core/prompts/system.ts](../src/core/prompts/system.ts)
|
||||
|
||||
- Includes operational behavior guiding intent-first workflow.
|
||||
|
||||
- **Pre-hook Injection in Task Flow:** [src/core/task/Task.ts](../src/core/task/Task.ts)
|
||||
- Injects intent context into request pipeline before model action execution.
|
||||
|
||||
### Gatekeeper Implementation
|
||||
|
||||
- **Gate enforcement location:** [src/core/assistant-message/presentAssistantMessage.ts](../src/core/assistant-message/presentAssistantMessage.ts)
|
||||
- Blocking behavior returns governance error when active intent context is absent/invalid, preventing destructive tool execution.
|
||||
|
||||
---
|
||||
|
||||
## 4) Phase 2: Hook Middleware
|
||||
|
||||
### Command Classification
|
||||
|
||||
- Implemented via [src/core/governance/CommandClassifier.ts](../src/core/governance/CommandClassifier.ts)
|
||||
- Categorizes actions into **SAFE** vs **DESTRUCTIVE** classes for policy routing.
|
||||
|
||||
### HITL Authorization
|
||||
|
||||
- Implemented via [src/core/governance/HITLAuthorizer.ts](../src/core/governance/HITLAuthorizer.ts)
|
||||
- Prompts user confirmation on destructive actions and enforces deny-path rejection.
|
||||
|
||||
### Scope Enforcement
|
||||
|
||||
- Implemented via [src/core/governance/ScopeEnforcer.ts](../src/core/governance/ScopeEnforcer.ts)
|
||||
- Extracts target paths and validates requested operations against intent-owned scope.
|
||||
|
||||
### Autonomous Recovery
|
||||
|
||||
- Denied/out-of-scope/invalid-intent cases return structured tool errors so the agent can recover by:
|
||||
1. selecting valid intent,
|
||||
2. re-reading context,
|
||||
3. producing scoped retries.
|
||||
|
||||
---
|
||||
|
||||
## 5) Phase 3: Traceability
|
||||
|
||||
### SHA-256 Content Hashing
|
||||
|
||||
- Implemented in [src/core/trace/ContentHasher.ts](../src/core/trace/ContentHasher.ts)
|
||||
- Deterministic hash of final file content provides immutable change fingerprint.
|
||||
|
||||
### Semantic Classification
|
||||
|
||||
- Implemented in [src/core/trace/SemanticClassifier.ts](../src/core/trace/SemanticClassifier.ts)
|
||||
- Lightweight classifier emits:
|
||||
- `REFACTOR`
|
||||
- `EVOLUTION`
|
||||
|
||||
### Trace Serialization
|
||||
|
||||
- Implemented in [src/core/trace/AgentTraceSerializer.ts](../src/core/trace/AgentTraceSerializer.ts)
|
||||
- Appends JSON Lines entries to [.orchestration/agent_trace.jsonl](../.orchestration/agent_trace.jsonl).
|
||||
|
||||
### JSONL Schema (Operational)
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-02-21T21:00:00.000Z",
|
||||
"intent_id": "INTENT-001",
|
||||
"file_path": "src/core/tools/WriteToFileTool.ts",
|
||||
"content_sha256": "hex_sha256_digest",
|
||||
"semantic_change": "REFACTOR | EVOLUTION",
|
||||
"tool": "write_to_file"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Phase 4: Concurrency
|
||||
|
||||
### Optimistic Locking Primitive
|
||||
|
||||
- Implemented in [src/core/concurrency/OptimisticLock.ts](../src/core/concurrency/OptimisticLock.ts):
|
||||
- `getCurrentHash(filePath)`
|
||||
- `validateLock(expectedHash, filePath)`
|
||||
- `StaleFileError`
|
||||
|
||||
### Write-path Integration
|
||||
|
||||
- Integrated (minimally/safely) in [src/core/tools/WriteToFileTool.ts](../src/core/tools/WriteToFileTool.ts):
|
||||
1. Capture initial hash after file existence resolution.
|
||||
2. Revalidate hash immediately before final save.
|
||||
3. On mismatch, block write and emit stale-file error.
|
||||
4. Clear hash state on completion/failure paths.
|
||||
|
||||
### Stale File Detection
|
||||
|
||||
If file content changes between read and write windows, write is rejected and the agent is instructed to re-read current content before retry.
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
- Returns explicit stale conflict error (`StaleFileError` semantics).
|
||||
- Prevents silent overwrite.
|
||||
- Forces reconciliation loop (read → regenerate patch/write).
|
||||
|
||||
---
|
||||
|
||||
## 7) Evaluation Rubric Self-Assessment
|
||||
|
||||
| Metric | How I Achieve Score 5 |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Intent-AST Correlation | [.orchestration/agent_trace.jsonl](../.orchestration/agent_trace.jsonl) links `intent_id` to content hash and file path after successful writes. |
|
||||
| Context Engineering | Dynamic intent context resolution from [.orchestration/active_intents.yaml](../.orchestration/active_intents.yaml), injected before execution. |
|
||||
| Hook Architecture | Middleware checks in [src/core/assistant-message/presentAssistantMessage.ts](../src/core/assistant-message/presentAssistantMessage.ts) gate destructive paths with policy layering. |
|
||||
| Orchestration | Intent-first flow + traceability + optimistic locking provide safe multi-agent behavior and conflict-aware writes. |
|
||||
|
||||
---
|
||||
|
||||
## 8) Screenshots (Submission Section)
|
||||
|
||||
Add these 4 screenshots with captions:
|
||||
|
||||
1. **Gatekeeper block without intent**
|
||||
|
||||
- Show Roo response containing governance error: “You must cite a valid active Intent ID”.
|
||||
|
||||
2. **HITL approval dialog**
|
||||
|
||||
- Show destructive action confirmation prompt before execution.
|
||||
|
||||
3. **Trace output with hash**
|
||||
|
||||
- Show [.orchestration/agent_trace.jsonl](../.orchestration/agent_trace.jsonl) containing `intent_id`, `file_path`, and `content_sha256`.
|
||||
|
||||
4. **Stale-file conflict error**
|
||||
- Show rejected write with stale/optimistic-lock error forcing re-read.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Primary Files by Phase
|
||||
|
||||
- **Phase 1:**
|
||||
[src/core/tools/SelectActiveIntentTool.ts](../src/core/tools/SelectActiveIntentTool.ts)
|
||||
[src/core/intent/IntentContextLoader.ts](../src/core/intent/IntentContextLoader.ts)
|
||||
[src/core/prompts/system.ts](../src/core/prompts/system.ts)
|
||||
[src/core/task/Task.ts](../src/core/task/Task.ts)
|
||||
|
||||
- **Phase 2:**
|
||||
[src/core/governance/CommandClassifier.ts](../src/core/governance/CommandClassifier.ts)
|
||||
[src/core/governance/HITLAuthorizer.ts](../src/core/governance/HITLAuthorizer.ts)
|
||||
[src/core/governance/ScopeEnforcer.ts](../src/core/governance/ScopeEnforcer.ts)
|
||||
[src/core/assistant-message/presentAssistantMessage.ts](../src/core/assistant-message/presentAssistantMessage.ts)
|
||||
|
||||
- **Phase 3:**
|
||||
[src/core/trace/ContentHasher.ts](../src/core/trace/ContentHasher.ts)
|
||||
[src/core/trace/SemanticClassifier.ts](../src/core/trace/SemanticClassifier.ts)
|
||||
[src/core/trace/AgentTraceSerializer.ts](../src/core/trace/AgentTraceSerializer.ts)
|
||||
[.orchestration/agent_trace.jsonl](../.orchestration/agent_trace.jsonl)
|
||||
|
||||
- **Phase 4:**
|
||||
[src/core/concurrency/OptimisticLock.ts](../src/core/concurrency/OptimisticLock.ts)
|
||||
[src/core/tools/WriteToFileTool.ts](../src/core/tools/WriteToFileTool.ts)
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — PDF Conversion Tips
|
||||
|
||||
- Use VS Code Markdown Preview + “Print to PDF”, or Pandoc:
|
||||
- `pandoc report.md -o report.pdf`
|
||||
- Ensure Mermaid diagrams are rendered before export (or pre-render to images if needed).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User Request] --> B{Has Intent?}
|
||||
B -->|No| C[Call select_active_intent]
|
||||
C --> D[Load Intent Context]
|
||||
D --> B
|
||||
|
||||
B -->|Yes| E{Command Type?}
|
||||
E -->|Safe| F[Execute Immediately]
|
||||
E -->|Destructive| G{In Scope?}
|
||||
G -->|No| H[Scope Violation Error]
|
||||
G -->|Yes| I{HITL Approved?}
|
||||
I -->|No| J[User Rejected Error]
|
||||
I -->|Yes| K[Execute Tool]
|
||||
|
||||
K --> L{File Changed?}
|
||||
L -->|Yes| M[Stale File Error]
|
||||
L -->|No| N[Write Success]
|
||||
N --> O[Record Trace]
|
||||
```
|
||||
0
final_lock_test.txt
Normal file
0
final_lock_test.txt
Normal file
0
final_tesst.txt
Normal file
0
final_tesst.txt
Normal file
0
final_test1.txt
Normal file
0
final_test1.txt
Normal file
1
gate_test.txt
Normal file
1
gate_test.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Locked
|
||||
1
governance_test.txt
Normal file
1
governance_test.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Testing the lock
|
||||
8
intent_map.md
Normal file
8
intent_map.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Intent-Code Mapping
|
||||
|
||||
| Intent ID | Target Files / Modules | Status |
|
||||
| --------- | ------------------------------------------------- | ----------- |
|
||||
| INT-001 | src/core/tools/WriteToFileTool.ts | IN PROGRESS |
|
||||
| INT-001 | src/core/assistant-msg/presentAssistantMessage.ts | COMPLETED |
|
||||
|
||||
[cite_start]**Notes:** This map links the high-level business logic in `.orchestration/active_intents.yaml` to specific AST changes in the source code.
|
||||
|
|
@ -39,6 +39,7 @@ export const toolNames = [
|
|||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
"switch_mode",
|
||||
"select_active_intent",
|
||||
"new_task",
|
||||
"codebase_search",
|
||||
"update_todo_list",
|
||||
|
|
|
|||
0
please test.txt
Normal file
0
please test.txt
Normal file
|
|
@ -547,6 +547,14 @@ export class NativeToolCallParser {
|
|||
}
|
||||
break
|
||||
|
||||
case "select_active_intent":
|
||||
if (partialArgs.intent_id !== undefined) {
|
||||
nativeArgs = {
|
||||
intent_id: partialArgs.intent_id,
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "update_todo_list":
|
||||
if (partialArgs.todos !== undefined) {
|
||||
nativeArgs = {
|
||||
|
|
@ -883,6 +891,14 @@ export class NativeToolCallParser {
|
|||
}
|
||||
break
|
||||
|
||||
case "select_active_intent":
|
||||
if (args.intent_id !== undefined) {
|
||||
nativeArgs = {
|
||||
intent_id: args.intent_id,
|
||||
} as NativeArgsFor<TName>
|
||||
}
|
||||
break
|
||||
|
||||
case "update_todo_list":
|
||||
if (args.todos !== undefined) {
|
||||
nativeArgs = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useMcpToolTool } from "../tools/UseMcpToolTool"
|
|||
import { accessMcpResourceTool } from "../tools/accessMcpResourceTool"
|
||||
import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool"
|
||||
import { switchModeTool } from "../tools/SwitchModeTool"
|
||||
import { SelectActiveIntentTool } from "../tools/SelectActiveIntentTool"
|
||||
import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool"
|
||||
import { newTaskTool } from "../tools/NewTaskTool"
|
||||
import { updateTodoListTool } from "../tools/UpdateTodoListTool"
|
||||
|
|
@ -40,6 +41,21 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
|
|||
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { sanitizeToolUseId } from "../../utils/tool-id"
|
||||
import { INVALID_ACTIVE_INTENT_ERROR, loadIntentContext } from "../intent/IntentContextLoader"
|
||||
import { isDestructiveCommand } from "../governance/CommandClassifier"
|
||||
import { showHITLApproval } from "../governance/HITLAuthorizer"
|
||||
import { enforceIntentScope, getToolTargetPaths } from "../governance/ScopeEnforcer"
|
||||
|
||||
const selectActiveIntentTool = new SelectActiveIntentTool()
|
||||
const GOVERNED_WRITE_TOOLS = new Set<ToolName>([
|
||||
"write_to_file",
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"search_replace",
|
||||
"apply_diff",
|
||||
"edit",
|
||||
"search_and_replace",
|
||||
])
|
||||
|
||||
/**
|
||||
* Processes and presents assistant message content to the user interface.
|
||||
|
|
@ -59,6 +75,9 @@ import { sanitizeToolUseId } from "../../utils/tool-id"
|
|||
*/
|
||||
|
||||
export async function presentAssistantMessage(cline: Task) {
|
||||
console.log("🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵")
|
||||
console.log("🔵 presentAssistantMessage.ts LOADED")
|
||||
console.log("🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵")
|
||||
if (cline.abort) {
|
||||
throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
|
||||
}
|
||||
|
|
@ -365,6 +384,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
return `[${block.name}]`
|
||||
case "switch_mode":
|
||||
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
|
||||
case "select_active_intent":
|
||||
return `[${block.name} for '${block.params.intent_id}']`
|
||||
case "codebase_search":
|
||||
return `[${block.name} for '${block.params.query}']`
|
||||
case "read_command_output":
|
||||
|
|
@ -675,6 +696,78 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!block.partial && GOVERNED_WRITE_TOOLS.has(block.name as ToolName)) {
|
||||
const provider = cline.providerRef.deref()
|
||||
const state = await provider?.getState()
|
||||
const activeIntentId = String((state as any)?.activeIntentId ?? "").trim()
|
||||
|
||||
let activeIntentContext: Awaited<ReturnType<typeof loadIntentContext>> = null
|
||||
if (activeIntentId) {
|
||||
try {
|
||||
activeIntentContext = await loadIntentContext(cline.cwd, activeIntentId)
|
||||
} catch {
|
||||
activeIntentContext = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeIntentContext) {
|
||||
cline.consecutiveMistakeCount++
|
||||
cline.recordToolError(block.name as ToolName, INVALID_ACTIVE_INTENT_ERROR)
|
||||
cline.didToolFailInCurrentTurn = true
|
||||
cline.pushToolResultToUserContent({
|
||||
type: "tool_result",
|
||||
tool_use_id: sanitizeToolUseId(toolCallId),
|
||||
content: formatResponse.toolError(INVALID_ACTIVE_INTENT_ERROR),
|
||||
is_error: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const targetPaths = getToolTargetPaths(block.name, (block.nativeArgs ?? {}) as Record<string, unknown>)
|
||||
const scopeCheck = enforceIntentScope(
|
||||
activeIntentContext.intent_id,
|
||||
cline.cwd,
|
||||
activeIntentContext.scope,
|
||||
targetPaths,
|
||||
)
|
||||
|
||||
if (!scopeCheck.allowed) {
|
||||
cline.consecutiveMistakeCount++
|
||||
cline.recordToolError(block.name as ToolName, scopeCheck.error)
|
||||
cline.didToolFailInCurrentTurn = true
|
||||
cline.pushToolResultToUserContent({
|
||||
type: "tool_result",
|
||||
tool_use_id: sanitizeToolUseId(toolCallId),
|
||||
content: formatResponse.toolError(scopeCheck.error),
|
||||
is_error: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!block.partial && isValidToolName(String(block.name), stateExperiments)) {
|
||||
if (isDestructiveCommand(block.name as ToolName)) {
|
||||
const approved = await showHITLApproval(block.name, block.nativeArgs ?? block.params)
|
||||
if (!approved) {
|
||||
const rejectionPayload = JSON.stringify({
|
||||
code: "operation_rejected",
|
||||
tool: block.name,
|
||||
message: "Operation rejected by user",
|
||||
})
|
||||
cline.consecutiveMistakeCount++
|
||||
cline.recordToolError(block.name as ToolName, rejectionPayload)
|
||||
cline.didToolFailInCurrentTurn = true
|
||||
cline.pushToolResultToUserContent({
|
||||
type: "tool_result",
|
||||
tool_use_id: sanitizeToolUseId(toolCallId),
|
||||
content: formatResponse.toolError(rejectionPayload),
|
||||
is_error: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (block.name) {
|
||||
case "write_to_file":
|
||||
await checkpointSaveAndMark(cline)
|
||||
|
|
@ -803,6 +896,20 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
pushToolResult,
|
||||
})
|
||||
break
|
||||
case "select_active_intent": {
|
||||
try {
|
||||
const intentId = block.nativeArgs?.intent_id ?? block.params.intent_id ?? ""
|
||||
const result = await selectActiveIntentTool.handle(
|
||||
{ intent_id: intentId },
|
||||
cline.cwd,
|
||||
cline.providerRef.deref(),
|
||||
)
|
||||
pushToolResult(result)
|
||||
} catch (error) {
|
||||
await handleError("loading active intent context", error as Error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "new_task":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
|
||||
|
|
|
|||
37
src/core/concurrency/OptimisticLock.ts
Normal file
37
src/core/concurrency/OptimisticLock.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fs from "fs/promises"
|
||||
|
||||
import { hashContent } from "../trace/ContentHasher"
|
||||
|
||||
export class StaleFileError extends Error {
|
||||
readonly expectedHash: string
|
||||
readonly currentHash: string
|
||||
readonly filePath: string
|
||||
|
||||
constructor(filePath: string, expectedHash: string, currentHash: string) {
|
||||
super(
|
||||
`STALE_FILE_ERROR: File '${filePath}' changed since it was last read (expected=${expectedHash}, current=${currentHash}). Re-read the file before applying changes.`,
|
||||
)
|
||||
this.name = "StaleFileError"
|
||||
this.filePath = filePath
|
||||
this.expectedHash = expectedHash
|
||||
this.currentHash = currentHash
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentHash(filePath: string): Promise<string> {
|
||||
const content = await fs.readFile(filePath, "utf8")
|
||||
return hashContent(content)
|
||||
}
|
||||
|
||||
export async function validateLock(expectedHash: string, filePath: string): Promise<boolean> {
|
||||
if (!expectedHash || !expectedHash.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const currentHash = await getCurrentHash(filePath)
|
||||
return currentHash === expectedHash.trim()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
49
src/core/governance/CommandClassifier.ts
Normal file
49
src/core/governance/CommandClassifier.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { ToolName } from "@roo-code/types"
|
||||
|
||||
export const SAFE_COMMANDS: ReadonlySet<ToolName> = new Set([
|
||||
"read_file",
|
||||
"list_files",
|
||||
"search_files",
|
||||
"codebase_search",
|
||||
"read_command_output",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
"switch_mode",
|
||||
"select_active_intent",
|
||||
"update_todo_list",
|
||||
"run_slash_command",
|
||||
"skill",
|
||||
"access_mcp_resource",
|
||||
])
|
||||
|
||||
export const DESTRUCTIVE_COMMANDS: ReadonlySet<ToolName> = new Set([
|
||||
"write_to_file",
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"search_replace",
|
||||
"apply_diff",
|
||||
"edit",
|
||||
"search_and_replace",
|
||||
"execute_command",
|
||||
"use_mcp_tool",
|
||||
"new_task",
|
||||
"generate_image",
|
||||
])
|
||||
|
||||
export type CommandRisk = "safe" | "destructive"
|
||||
|
||||
export function classifyCommand(toolName: ToolName): CommandRisk {
|
||||
if (DESTRUCTIVE_COMMANDS.has(toolName)) {
|
||||
return "destructive"
|
||||
}
|
||||
|
||||
if (SAFE_COMMANDS.has(toolName)) {
|
||||
return "safe"
|
||||
}
|
||||
|
||||
return "destructive"
|
||||
}
|
||||
|
||||
export function isDestructiveCommand(toolName: ToolName): boolean {
|
||||
return classifyCommand(toolName) === "destructive"
|
||||
}
|
||||
11
src/core/governance/HITLAuthorizer.ts
Normal file
11
src/core/governance/HITLAuthorizer.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as vscode from "vscode"
|
||||
|
||||
export async function showHITLApproval(toolName: string, payloadPreview?: unknown): Promise<boolean> {
|
||||
const detail = payloadPreview
|
||||
? `\n\nPayload Preview:\n${JSON.stringify(payloadPreview, null, 2).slice(0, 500)}`
|
||||
: ""
|
||||
const message = `Destructive operation requested: ${toolName}.${detail}`
|
||||
|
||||
const approval = await vscode.window.showWarningMessage(message, { modal: true }, "Approve", "Reject")
|
||||
return approval === "Approve"
|
||||
}
|
||||
90
src/core/governance/ScopeEnforcer.ts
Normal file
90
src/core/governance/ScopeEnforcer.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import path from "path"
|
||||
|
||||
interface IntentScope {
|
||||
files?: string[]
|
||||
}
|
||||
|
||||
const PATCH_HEADER_RE = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)$/
|
||||
|
||||
function normalizePathForMatch(input: string): string {
|
||||
return input.replaceAll("\\", "/").replace(/^\.?\//, "")
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
function globToRegExp(globPattern: string): RegExp {
|
||||
const normalized = normalizePathForMatch(globPattern)
|
||||
const withDoubleStar = normalized.replaceAll("**", "__DOUBLE_STAR__")
|
||||
const withSingleStar = withDoubleStar.replaceAll("*", "__SINGLE_STAR__")
|
||||
const escaped = escapeRegExp(withSingleStar)
|
||||
.replaceAll("__DOUBLE_STAR__", ".*")
|
||||
.replaceAll("__SINGLE_STAR__", "[^/]*")
|
||||
return new RegExp(`^${escaped}$`)
|
||||
}
|
||||
|
||||
function isPathAllowed(filePath: string, allowedPatterns: string[]): boolean {
|
||||
if (allowedPatterns.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathForMatch(filePath)
|
||||
return allowedPatterns.some((pattern) => globToRegExp(pattern).test(normalizedPath))
|
||||
}
|
||||
|
||||
function extractPathsFromApplyPatch(patch: string): string[] {
|
||||
return patch
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => PATCH_HEADER_RE.exec(line)?.[1])
|
||||
.filter((value): value is string => !!value)
|
||||
}
|
||||
|
||||
export function getToolTargetPaths(toolName: string, nativeArgs?: Record<string, unknown>): string[] {
|
||||
if (!nativeArgs) {
|
||||
return []
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "write_to_file":
|
||||
case "apply_diff":
|
||||
return typeof nativeArgs.path === "string" ? [nativeArgs.path] : []
|
||||
case "edit":
|
||||
case "search_and_replace":
|
||||
case "search_replace":
|
||||
case "edit_file":
|
||||
return typeof nativeArgs.file_path === "string" ? [nativeArgs.file_path] : []
|
||||
case "apply_patch":
|
||||
return typeof nativeArgs.patch === "string" ? extractPathsFromApplyPatch(nativeArgs.patch) : []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceIntentScope(
|
||||
intentId: string,
|
||||
cwd: string,
|
||||
intentScope: IntentScope | unknown,
|
||||
targetPaths: string[],
|
||||
): { allowed: true } | { allowed: false; error: string } {
|
||||
const filePatterns = Array.isArray((intentScope as IntentScope)?.files)
|
||||
? ((intentScope as IntentScope).files as string[])
|
||||
: []
|
||||
|
||||
if (targetPaths.length === 0) {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
for (const targetPath of targetPaths) {
|
||||
const relativeTarget = path.isAbsolute(targetPath) ? path.relative(cwd, targetPath) : targetPath
|
||||
if (!isPathAllowed(relativeTarget, filePatterns)) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `Scope Violation: ${intentId} is not authorized to edit [${relativeTarget}]. Request scope expansion.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true }
|
||||
}
|
||||
17
src/core/governance/__tests__/CommandClassifier.spec.ts
Normal file
17
src/core/governance/__tests__/CommandClassifier.spec.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { classifyCommand, isDestructiveCommand } from "../CommandClassifier"
|
||||
|
||||
describe("CommandClassifier", () => {
|
||||
it("classifies read_file as safe", () => {
|
||||
expect(classifyCommand("read_file")).toBe("safe")
|
||||
expect(isDestructiveCommand("read_file")).toBe(false)
|
||||
})
|
||||
|
||||
it("classifies write_to_file as destructive", () => {
|
||||
expect(classifyCommand("write_to_file")).toBe("destructive")
|
||||
expect(isDestructiveCommand("write_to_file")).toBe(true)
|
||||
})
|
||||
|
||||
it("classifies execute_command as destructive", () => {
|
||||
expect(classifyCommand("execute_command")).toBe("destructive")
|
||||
})
|
||||
})
|
||||
33
src/core/governance/__tests__/ScopeEnforcer.spec.ts
Normal file
33
src/core/governance/__tests__/ScopeEnforcer.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { enforceIntentScope, getToolTargetPaths } from "../ScopeEnforcer"
|
||||
|
||||
describe("ScopeEnforcer", () => {
|
||||
it("extracts target path for write_to_file", () => {
|
||||
const paths = getToolTargetPaths("write_to_file", { path: "src/core/task/Task.ts" })
|
||||
expect(paths).toEqual(["src/core/task/Task.ts"])
|
||||
})
|
||||
|
||||
it("extracts target paths from apply_patch headers", () => {
|
||||
const patch = `*** Begin Patch\n*** Update File: src/core/task/Task.ts\n*** End Patch`
|
||||
const paths = getToolTargetPaths("apply_patch", { patch })
|
||||
expect(paths).toEqual(["src/core/task/Task.ts"])
|
||||
})
|
||||
|
||||
it("allows path inside scope", () => {
|
||||
const result = enforceIntentScope("REQ-001", "/workspace", { files: ["src/core/**"] }, [
|
||||
"src/core/task/Task.ts",
|
||||
])
|
||||
expect(result).toEqual({ allowed: true })
|
||||
})
|
||||
|
||||
it("blocks path outside scope with required message shape", () => {
|
||||
const result = enforceIntentScope("REQ-001", "/workspace", { files: ["src/core/**"] }, [
|
||||
"src/webview-ui/App.tsx",
|
||||
])
|
||||
expect(result.allowed).toBe(false)
|
||||
if (!result.allowed) {
|
||||
expect(result.error).toBe(
|
||||
"Scope Violation: REQ-001 is not authorized to edit [src/webview-ui/App.tsx]. Request scope expansion.",
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
131
src/core/intent/IntentContextLoader.ts
Normal file
131
src/core/intent/IntentContextLoader.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import * as yaml from "yaml"
|
||||
|
||||
export const ACTIVE_INTENTS_RELATIVE_PATH = path.join(".orchestration", "active_intents.yaml")
|
||||
export const TRACE_LOG_RELATIVE_PATH = ".roo-tool-trace.log"
|
||||
export const INVALID_ACTIVE_INTENT_ERROR = "You must cite a valid active Intent ID"
|
||||
|
||||
export interface IntentContext {
|
||||
intent_id: string
|
||||
constraints: unknown
|
||||
scope: unknown
|
||||
relatedTraceEntries: string[]
|
||||
}
|
||||
|
||||
type IntentEntry = Record<string, unknown>
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
function normalizeIntentEntries(data: unknown): IntentEntry[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data.filter(Boolean) as IntentEntry[]
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const root = data as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(root.active_intents)) {
|
||||
return root.active_intents.filter(Boolean) as IntentEntry[]
|
||||
}
|
||||
|
||||
if (Array.isArray(root.intents)) {
|
||||
return root.intents.filter(Boolean) as IntentEntry[]
|
||||
}
|
||||
|
||||
return Object.entries(root).map(([intent_id, entry]) => ({
|
||||
intent_id,
|
||||
...(entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function toIntentId(entry: IntentEntry): string {
|
||||
return String(entry.intent_id ?? entry.id ?? "").trim()
|
||||
}
|
||||
|
||||
function parseTraceLines(traceContent: string, intentId: string): string[] {
|
||||
const normalizedIntentId = intentId.trim()
|
||||
if (!normalizedIntentId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = traceContent
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return candidates.filter((line) => {
|
||||
const lower = line.toLowerCase()
|
||||
return (
|
||||
line.includes(normalizedIntentId) ||
|
||||
lower.includes(`intent_id=${normalizedIntentId.toLowerCase()}`) ||
|
||||
lower.includes(`[intent:${normalizedIntentId.toLowerCase()}]`)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function readYamlFile(workspaceRoot: string): Promise<unknown> {
|
||||
const yamlPath = path.join(workspaceRoot, ACTIVE_INTENTS_RELATIVE_PATH)
|
||||
const content = await fs.readFile(yamlPath, "utf-8")
|
||||
return yaml.parse(content)
|
||||
}
|
||||
|
||||
async function readTraceFile(workspaceRoot: string): Promise<string> {
|
||||
const tracePath = path.join(workspaceRoot, TRACE_LOG_RELATIVE_PATH)
|
||||
try {
|
||||
return await fs.readFile(tracePath, "utf-8")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadIntentContext(workspaceRoot: string, intentId: string): Promise<IntentContext | null> {
|
||||
const data = await readYamlFile(workspaceRoot)
|
||||
const intents = normalizeIntentEntries(data)
|
||||
const match = intents.find((entry) => toIntentId(entry) === intentId.trim())
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const traceContent = await readTraceFile(workspaceRoot)
|
||||
const relatedTraceEntries = parseTraceLines(traceContent, intentId)
|
||||
|
||||
return {
|
||||
intent_id: toIntentId(match),
|
||||
constraints: match.constraints ?? {},
|
||||
scope: match.scope ?? {},
|
||||
relatedTraceEntries,
|
||||
}
|
||||
}
|
||||
|
||||
export function renderIntentContextXml(context: Pick<IntentContext, "constraints" | "scope">): string {
|
||||
const constraintsJson = xmlEscape(JSON.stringify(context.constraints ?? {}, null, 2))
|
||||
const scopeJson = xmlEscape(JSON.stringify(context.scope ?? {}, null, 2))
|
||||
|
||||
return `<intent_context>\n <constraints>${constraintsJson}</constraints>\n <scope>${scopeJson}</scope>\n</intent_context>`
|
||||
}
|
||||
|
||||
export function renderIntentPreHookXml(context: IntentContext): string {
|
||||
const traceXml =
|
||||
context.relatedTraceEntries.length > 0
|
||||
? `\n <related_trace>\n${context.relatedTraceEntries
|
||||
.map((entry) => ` <entry>${xmlEscape(entry)}</entry>`)
|
||||
.join("\n")}\n </related_trace>`
|
||||
: ""
|
||||
|
||||
const constraintsJson = xmlEscape(JSON.stringify(context.constraints ?? {}, null, 2))
|
||||
const scopeJson = xmlEscape(JSON.stringify(context.scope ?? {}, null, 2))
|
||||
|
||||
return `<intent_context_snapshot>\n <intent_id>${xmlEscape(context.intent_id)}</intent_id>\n <constraints>${constraintsJson}</constraints>\n <scope>${scopeJson}</scope>${traceXml}\n</intent_context_snapshot>`
|
||||
}
|
||||
55
src/core/intent/__tests__/IntentContextLoader.spec.ts
Normal file
55
src/core/intent/__tests__/IntentContextLoader.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
import { loadIntentContext, renderIntentContextXml } from "../IntentContextLoader"
|
||||
|
||||
describe("IntentContextLoader", () => {
|
||||
it("loads matching intent from active_intents.yaml and extracts constraints/scope", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "intent-loader-"))
|
||||
const orchestrationDir = path.join(tempRoot, ".orchestration")
|
||||
await fs.mkdir(orchestrationDir, { recursive: true })
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(orchestrationDir, "active_intents.yaml"),
|
||||
`active_intents:
|
||||
- intent_id: INTENT-TEST
|
||||
constraints:
|
||||
must_not:
|
||||
- direct-db-write
|
||||
scope:
|
||||
files:
|
||||
- src/**
|
||||
`,
|
||||
"utf8",
|
||||
)
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, ".roo-tool-trace.log"),
|
||||
`[2026-02-21T00:00:00.000Z] TOOL EXECUTED: select_active_intent [intent:INTENT-TEST] intent_id=INTENT-TEST\n`,
|
||||
"utf8",
|
||||
)
|
||||
|
||||
const result = await loadIntentContext(tempRoot, "INTENT-TEST")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.intent_id).toBe("INTENT-TEST")
|
||||
expect(result?.constraints).toEqual({ must_not: ["direct-db-write"] })
|
||||
expect(result?.scope).toEqual({ files: ["src/**"] })
|
||||
expect(result?.relatedTraceEntries.length).toBe(1)
|
||||
|
||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("returns XML with constraints and scope only", () => {
|
||||
const xml = renderIntentContextXml({
|
||||
constraints: { must: ["x"] },
|
||||
scope: { files: ["src/**"] },
|
||||
})
|
||||
|
||||
expect(xml).toContain("<intent_context>")
|
||||
expect(xml).toContain("<constraints>")
|
||||
expect(xml).toContain("<scope>")
|
||||
expect(xml).not.toContain("<intent_id>")
|
||||
})
|
||||
})
|
||||
|
|
@ -25,6 +25,9 @@ import {
|
|||
getSkillsSection,
|
||||
} from "./sections"
|
||||
|
||||
const INTENT_DRIVEN_ARCHITECT_RULE =
|
||||
"You are an Intent-Driven Architect. You CANNOT write code immediately. Your first action MUST be to analyze the user request and call select_active_intent to load the necessary context."
|
||||
|
||||
// Helper function to get prompt component, filtering out empty objects
|
||||
export function getPromptComponent(
|
||||
customModePrompts: CustomModePrompts | undefined,
|
||||
|
|
@ -82,7 +85,9 @@ async function generatePrompt(
|
|||
// Tools catalog is not included in the system prompt.
|
||||
const toolsCatalog = ""
|
||||
|
||||
const basePrompt = `${roleDefinition}
|
||||
const basePrompt = `${INTENT_DRIVEN_ARCHITECT_RULE}
|
||||
|
||||
${roleDefinition}
|
||||
|
||||
${markdownFormattingSection()}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import newTask from "./new_task"
|
|||
import readCommandOutput from "./read_command_output"
|
||||
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
|
||||
import runSlashCommand from "./run_slash_command"
|
||||
import selectActiveIntent from "./select_active_intent"
|
||||
import skill from "./skill"
|
||||
import searchReplace from "./search_replace"
|
||||
import edit_file from "./edit_file"
|
||||
|
|
@ -60,6 +61,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
|
|||
readCommandOutput,
|
||||
createReadFileTool(readFileOptions),
|
||||
runSlashCommand,
|
||||
selectActiveIntent,
|
||||
skill,
|
||||
searchReplace,
|
||||
edit_file,
|
||||
|
|
|
|||
25
src/core/prompts/tools/native-tools/select_active_intent.ts
Normal file
25
src/core/prompts/tools/native-tools/select_active_intent.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type OpenAI from "openai"
|
||||
|
||||
const SELECT_ACTIVE_INTENT_DESCRIPTION = `Load the active intent context from .orchestration/active_intents.yaml by intent ID. Use this before planning or coding so constraints and scope are explicitly available in the next turn.`
|
||||
|
||||
const INTENT_ID_PARAMETER_DESCRIPTION = `Intent ID to load from .orchestration/active_intents.yaml (for example: intent-1)`
|
||||
|
||||
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
|
||||
|
|
@ -132,6 +132,7 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
|
|||
import { MessageManager } from "../message-manager"
|
||||
import { validateAndFixToolResultIds } from "./validateToolResultIds"
|
||||
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
|
||||
import { INVALID_ACTIVE_INTENT_ERROR, loadIntentContext, renderIntentPreHookXml } from "../intent/IntentContextLoader"
|
||||
|
||||
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
|
||||
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
|
||||
|
|
@ -2643,9 +2644,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
return true
|
||||
})
|
||||
|
||||
// Add environment details as its own text block, separate from tool
|
||||
// results.
|
||||
let finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
|
||||
const intentPreHookContext = await this.buildIntentPreHookContext()
|
||||
|
||||
// Add environment details and pre-hook intent context as dedicated text blocks.
|
||||
let finalUserContent = [
|
||||
...contentWithoutEnvDetails,
|
||||
{ type: "text" as const, text: environmentDetails },
|
||||
{ type: "text" as const, text: intentPreHookContext },
|
||||
]
|
||||
// Only add user message to conversation history if:
|
||||
// 1. This is the first attempt (retryAttempt === 0), AND
|
||||
// 2. The original userContent was not empty (empty signals delegation resume where
|
||||
|
|
@ -3818,6 +3824,26 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
})()
|
||||
}
|
||||
|
||||
private async buildIntentPreHookContext(): Promise<string> {
|
||||
const state = await this.providerRef.deref()?.getState()
|
||||
const activeIntentId = String((state as any)?.activeIntentId ?? "").trim()
|
||||
|
||||
if (!activeIntentId) {
|
||||
return `<intent_gatekeeper_error>${INVALID_ACTIVE_INTENT_ERROR}</intent_gatekeeper_error>`
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await loadIntentContext(this.cwd, activeIntentId)
|
||||
if (!context) {
|
||||
return `<intent_gatekeeper_error>${INVALID_ACTIVE_INTENT_ERROR}</intent_gatekeeper_error>`
|
||||
}
|
||||
|
||||
return renderIntentPreHookXml(context)
|
||||
} catch {
|
||||
return `<intent_gatekeeper_error>${INVALID_ACTIVE_INTENT_ERROR}</intent_gatekeeper_error>`
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentProfileId(state: any): string {
|
||||
return (
|
||||
state?.listApiConfigMeta?.find((profile: any) => profile.name === state?.currentApiConfigName)?.id ??
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
|
|||
readonly name = "apply_diff" as const
|
||||
|
||||
async execute(params: ApplyDiffParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡 TOOL EXECUTED: " + this.name, "color: orange; font-size: 16px; font-weight: bold")
|
||||
console.log("%c🟡 File operation in progress", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
const { askApproval, handleError, pushToolResult } = callbacks
|
||||
let { path: relPath, diff: diffContent } = params
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
|
|||
}
|
||||
|
||||
async execute(params: ApplyPatchParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡 TOOL EXECUTED: " + this.name, "color: orange; font-size: 16px; font-weight: bold")
|
||||
console.log("%c🟡 File operation in progress", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
const { patch } = params
|
||||
const { askApproval, handleError, pushToolResult } = callbacks
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,14 @@ export class EditFileTool extends BaseTool<"edit_file"> {
|
|||
private partialToolAskRelPath: string | undefined
|
||||
|
||||
async execute(params: EditFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡 TOOL EXECUTED: " + this.name, "color: orange; font-size: 16px; font-weight: bold")
|
||||
console.log("%c🟡 File operation in progress", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
// Coerce old_string/new_string to handle malformed native tool calls where they could be non-strings.
|
||||
// In native mode, malformed calls can pass numbers/objects; normalize those to "" to avoid later crashes.
|
||||
const file_path = params.file_path
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ export class EditTool extends BaseTool<"edit"> {
|
|||
readonly name = "edit" as const
|
||||
|
||||
async execute(params: EditParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡 TOOL EXECUTED: " + this.name, "color: orange; font-size: 16px; font-weight: bold")
|
||||
console.log("%c🟡 File operation in progress", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
const { file_path: relPath, old_string: oldString, new_string: newString, replace_all: replaceAll } = params
|
||||
const { askApproval, handleError, pushToolResult } = callbacks
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
|
|||
readonly name = "search_replace" as const
|
||||
|
||||
async execute(params: SearchReplaceParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡 TOOL EXECUTED: " + this.name, "color: orange; font-size: 16px; font-weight: bold")
|
||||
console.log("%c🟡 File operation in progress", "color: orange; font-size: 16px")
|
||||
console.log("%c🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡🟡", "color: orange; font-size: 16px")
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
const { file_path, old_string, new_string } = params
|
||||
const { askApproval, handleError, pushToolResult } = callbacks
|
||||
|
||||
|
|
|
|||
43
src/core/tools/SelectActiveIntentTool.ts
Normal file
43
src/core/tools/SelectActiveIntentTool.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { ACTIVE_INTENTS_RELATIVE_PATH, loadIntentContext, renderIntentContextXml } from "../intent/IntentContextLoader"
|
||||
|
||||
export class SelectActiveIntentTool {
|
||||
async handle(params: { intent_id: string }, workspaceRoot: string, provider?: any): Promise<string> {
|
||||
const intentId = params.intent_id?.trim()
|
||||
|
||||
if (!intentId) {
|
||||
return "ERROR: Missing required parameter 'intent_id'."
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await loadIntentContext(workspaceRoot, intentId)
|
||||
|
||||
if (!context) {
|
||||
return `ERROR: Intent '${intentId}' not found in .orchestration/active_intents.yaml.`
|
||||
}
|
||||
|
||||
await provider?.updateGlobalState("activeIntentId", intentId)
|
||||
|
||||
const tracePath = path.join(workspaceRoot, ".roo-tool-trace.log")
|
||||
await fs
|
||||
.appendFile(
|
||||
tracePath,
|
||||
`[${new Date().toISOString()}] TOOL EXECUTED: select_active_intent [intent:${intentId}] intent_id=${intentId}\n`,
|
||||
"utf8",
|
||||
)
|
||||
.catch(() => {})
|
||||
|
||||
return renderIntentContextXml(context)
|
||||
} catch (error) {
|
||||
if ((error as any)?.code === "ENOENT") {
|
||||
if ((error as any)?.path?.toString()?.includes(ACTIVE_INTENTS_RELATIVE_PATH)) {
|
||||
return "ERROR: Governance sidecar not found at .orchestration/active_intents.yaml. Please initialize Phase 0 first."
|
||||
}
|
||||
return "ERROR: Governance sidecar not found. Please ensure .orchestration/active_intents.yaml exists."
|
||||
}
|
||||
return `ERROR: Failed to read sidecar: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
|||
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
||||
import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
||||
import type { ToolUse } from "../../shared/tools"
|
||||
import { INVALID_ACTIVE_INTENT_ERROR } from "../intent/IntentContextLoader"
|
||||
import { appendAgentTrace } from "../trace/AgentTraceSerializer"
|
||||
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
|
|
@ -25,10 +27,17 @@ interface WriteToFileParams {
|
|||
|
||||
export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
||||
readonly name = "write_to_file" as const
|
||||
private initialFileHash: string | undefined
|
||||
|
||||
async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
try {
|
||||
const tracePath = path.join(task.cwd, ".roo-tool-trace.log")
|
||||
await fs.appendFile(tracePath, `[${new Date().toISOString()}] TOOL EXECUTED: ${this.name}\n`, "utf8")
|
||||
} catch {}
|
||||
const { pushToolResult, handleError, askApproval } = callbacks
|
||||
const relPath = params.path
|
||||
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath || "")
|
||||
|
||||
let newContent = params.content
|
||||
|
||||
if (!relPath) {
|
||||
|
|
@ -47,7 +56,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
return
|
||||
}
|
||||
|
||||
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
|
||||
const hasSelectedActiveIntent = (task.toolUsage.select_active_intent?.attempts ?? 0) > 0
|
||||
const provider = task.providerRef.deref()
|
||||
const state = await provider?.getState()
|
||||
const hasActiveIntentState = !!(state as any)?.activeIntentId
|
||||
|
||||
if (!hasSelectedActiveIntent && !hasActiveIntentState) {
|
||||
const governanceError = `GOVERNANCE ERROR: ${INVALID_ACTIVE_INTENT_ERROR} before modifying files.`
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("write_to_file", governanceError)
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(formatResponse.toolError(governanceError))
|
||||
await task.diffViewProvider.reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (!accessAllowed) {
|
||||
await task.say("rooignore_error", relPath)
|
||||
|
|
@ -67,6 +89,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
task.diffViewProvider.editType = fileExists ? "modify" : "create"
|
||||
}
|
||||
|
||||
this.initialFileHash = undefined
|
||||
if (fileExists) {
|
||||
try {
|
||||
const { getCurrentHash } = await import("../concurrency/OptimisticLock")
|
||||
this.initialFileHash = await getCurrentHash(absolutePath)
|
||||
} catch {
|
||||
this.initialFileHash = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directories early for new files to prevent ENOENT errors
|
||||
// in subsequent operations (e.g., diffViewProvider.open, fs.readFile)
|
||||
if (!fileExists) {
|
||||
|
|
@ -101,6 +133,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
|
||||
const provider = task.providerRef.deref()
|
||||
const state = await provider?.getState()
|
||||
const activeIntentId = String((state as any)?.activeIntentId ?? "").trim()
|
||||
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
|
||||
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
|
||||
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
|
||||
|
|
@ -108,6 +141,30 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
||||
)
|
||||
|
||||
const validateOptimisticLockOrThrow = async () => {
|
||||
if (!fileExists || !this.initialFileHash) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { validateLock, getCurrentHash, StaleFileError } = await import(
|
||||
"../concurrency/OptimisticLock"
|
||||
)
|
||||
const isLockValid = await validateLock(this.initialFileHash, absolutePath)
|
||||
if (!isLockValid) {
|
||||
const currentHash = await getCurrentHash(absolutePath).catch(() => "unavailable")
|
||||
throw new StaleFileError(relPath, this.initialFileHash, currentHash)
|
||||
}
|
||||
} catch (error) {
|
||||
const { StaleFileError } = await import("../concurrency/OptimisticLock").catch(() => ({
|
||||
StaleFileError: undefined,
|
||||
}))
|
||||
if (StaleFileError && error instanceof StaleFileError) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPreventFocusDisruptionEnabled) {
|
||||
task.diffViewProvider.editType = fileExists ? "modify" : "create"
|
||||
if (fileExists) {
|
||||
|
|
@ -133,6 +190,22 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await validateOptimisticLockOrThrow()
|
||||
} catch (error) {
|
||||
const staleErrorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "STALE_FILE_ERROR: File changed since it was last read. Re-read the file before applying changes."
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("write_to_file", staleErrorMessage)
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(formatResponse.toolError(staleErrorMessage))
|
||||
await task.diffViewProvider.reset()
|
||||
this.resetPartialState()
|
||||
return
|
||||
}
|
||||
|
||||
await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
|
||||
} else {
|
||||
if (!task.diffViewProvider.isEditing) {
|
||||
|
|
@ -166,9 +239,28 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await validateOptimisticLockOrThrow()
|
||||
} catch (error) {
|
||||
const staleErrorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "STALE_FILE_ERROR: File changed since it was last read. Re-read the file before applying changes."
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("write_to_file", staleErrorMessage)
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(formatResponse.toolError(staleErrorMessage))
|
||||
await task.diffViewProvider.revertChanges()
|
||||
await task.diffViewProvider.reset()
|
||||
this.resetPartialState()
|
||||
return
|
||||
}
|
||||
|
||||
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
||||
}
|
||||
|
||||
this.initialFileHash = undefined
|
||||
|
||||
if (relPath) {
|
||||
await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
|
||||
}
|
||||
|
|
@ -179,6 +271,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
|
||||
pushToolResult(message)
|
||||
|
||||
if (activeIntentId && relPath) {
|
||||
await appendAgentTrace({
|
||||
workspaceRoot: task.cwd,
|
||||
activeIntentId,
|
||||
filePath: relPath,
|
||||
content: newContent,
|
||||
toolName: this.name,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await task.diffViewProvider.reset()
|
||||
this.resetPartialState()
|
||||
|
||||
|
|
@ -190,6 +292,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
|||
await task.diffViewProvider.reset()
|
||||
this.resetPartialState()
|
||||
return
|
||||
} finally {
|
||||
this.initialFileHash = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
64
src/core/tools/__tests__/selectActiveIntentTool.spec.ts
Normal file
64
src/core/tools/__tests__/selectActiveIntentTool.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { SelectActiveIntentTool } from "../SelectActiveIntentTool"
|
||||
import * as loader from "../../intent/IntentContextLoader"
|
||||
|
||||
vi.mock("../../intent/IntentContextLoader", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../intent/IntentContextLoader")>(
|
||||
"../../intent/IntentContextLoader",
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
loadIntentContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("SelectActiveIntentTool", () => {
|
||||
const mockedLoadIntentContext = vi.mocked(loader.loadIntentContext)
|
||||
const tool = new SelectActiveIntentTool()
|
||||
const workspaceRoot = "/workspace"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns intent_context XML for matching intent in intents array", async () => {
|
||||
mockedLoadIntentContext.mockResolvedValue({
|
||||
intent_id: "intent-1",
|
||||
constraints: {
|
||||
must_not: ["direct_db_writes"],
|
||||
},
|
||||
scope: {
|
||||
files: ["src/**"],
|
||||
},
|
||||
relatedTraceEntries: [],
|
||||
})
|
||||
|
||||
const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot)
|
||||
|
||||
expect(result).toContain("<intent_context>")
|
||||
expect(result).toContain("<constraints>")
|
||||
expect(result).toContain("direct_db_writes")
|
||||
expect(result).toContain("<scope>")
|
||||
expect(result).toContain("src/**")
|
||||
expect(result).toContain("</intent_context>")
|
||||
})
|
||||
|
||||
it("returns not found error when intent_id does not exist", async () => {
|
||||
mockedLoadIntentContext.mockResolvedValue(null)
|
||||
|
||||
const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot)
|
||||
|
||||
expect(result).toBe("ERROR: Intent 'intent-1' not found in .orchestration/active_intents.yaml.")
|
||||
})
|
||||
|
||||
it("returns initialization error when sidecar file is missing", async () => {
|
||||
const missingFileError = Object.assign(new Error("ENOENT"), { code: "ENOENT" })
|
||||
mockedLoadIntentContext.mockRejectedValue(missingFileError)
|
||||
|
||||
const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot)
|
||||
|
||||
expect(result).toBe(
|
||||
"ERROR: Governance sidecar not found. Please ensure .orchestration/active_intents.yaml exists.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -123,6 +123,10 @@ describe("writeToFileTool", () => {
|
|||
mockCline.cwd = "/"
|
||||
mockCline.consecutiveMistakeCount = 0
|
||||
mockCline.didEditFile = false
|
||||
mockCline.didToolFailInCurrentTurn = false
|
||||
mockCline.toolUsage = {
|
||||
select_active_intent: { attempts: 1, failures: 0 },
|
||||
}
|
||||
mockCline.diffStrategy = undefined
|
||||
mockCline.providerRef = {
|
||||
deref: vi.fn().mockReturnValue({
|
||||
|
|
@ -141,6 +145,7 @@ describe("writeToFileTool", () => {
|
|||
originalContent: "",
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
saveDirectly: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn().mockResolvedValue(undefined),
|
||||
revertChanges: vi.fn().mockResolvedValue(undefined),
|
||||
saveChanges: vi.fn().mockResolvedValue({
|
||||
|
|
@ -236,6 +241,22 @@ describe("writeToFileTool", () => {
|
|||
}
|
||||
|
||||
describe("access control", () => {
|
||||
it("blocks write when no active intent has been selected in this session", async () => {
|
||||
mockCline.toolUsage = {}
|
||||
|
||||
const result = await executeWriteFileTool({})
|
||||
|
||||
expect(result).toBe(
|
||||
"Error: GOVERNANCE ERROR: You must cite a valid active Intent ID before modifying files.",
|
||||
)
|
||||
expect(mockCline.recordToolError).toHaveBeenCalledWith(
|
||||
"write_to_file",
|
||||
"GOVERNANCE ERROR: You must cite a valid active Intent ID before modifying files.",
|
||||
)
|
||||
expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled()
|
||||
expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("validates and allows access when rooIgnoreController permits", async () => {
|
||||
await executeWriteFileTool({}, { accessAllowed: true })
|
||||
|
||||
|
|
|
|||
96
src/core/trace/AgentTraceSerializer.ts
Normal file
96
src/core/trace/AgentTraceSerializer.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import * as yaml from "yaml"
|
||||
|
||||
import { hashContent } from "./ContentHasher"
|
||||
import { classifySemanticChange, type SemanticChangeType } from "./SemanticClassifier"
|
||||
|
||||
const ACTIVE_INTENTS_PATH = path.join(".orchestration", "active_intents.yaml")
|
||||
const AGENT_TRACE_PATH = path.join(".orchestration", "agent_trace.jsonl")
|
||||
|
||||
interface IntentRecord {
|
||||
intent_id?: unknown
|
||||
id?: unknown
|
||||
title?: unknown
|
||||
scope?: {
|
||||
operations?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIntentRecords(data: unknown): IntentRecord[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data as IntentRecord[]
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const root = data as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(root.active_intents)) {
|
||||
return root.active_intents as IntentRecord[]
|
||||
}
|
||||
|
||||
if (Array.isArray(root.intents)) {
|
||||
return root.intents as IntentRecord[]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeIntentId(record: IntentRecord): string {
|
||||
return String(record.intent_id ?? record.id ?? "").trim()
|
||||
}
|
||||
|
||||
async function loadIntentMetadata(workspaceRoot: string, activeIntentId: string): Promise<IntentRecord | undefined> {
|
||||
if (!activeIntentId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(path.join(workspaceRoot, ACTIVE_INTENTS_PATH), "utf8")
|
||||
const parsed = yaml.parse(content)
|
||||
const intents = normalizeIntentRecords(parsed)
|
||||
return intents.find((record) => normalizeIntentId(record) === activeIntentId)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgentTraceAppendInput {
|
||||
workspaceRoot: string
|
||||
activeIntentId: string
|
||||
filePath: string
|
||||
content: string
|
||||
toolName: string
|
||||
}
|
||||
|
||||
interface AgentTraceEntry {
|
||||
timestamp: string
|
||||
intent_id: string
|
||||
file_path: string
|
||||
content_sha256: string
|
||||
semantic_change: SemanticChangeType
|
||||
tool: string
|
||||
}
|
||||
|
||||
export async function appendAgentTrace(input: AgentTraceAppendInput): Promise<void> {
|
||||
const intent = await loadIntentMetadata(input.workspaceRoot, input.activeIntentId)
|
||||
const semanticChange = classifySemanticChange({
|
||||
intentTitle: intent?.title,
|
||||
intentOperations: intent?.scope?.operations,
|
||||
filePath: input.filePath,
|
||||
})
|
||||
|
||||
const entry: AgentTraceEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
intent_id: input.activeIntentId,
|
||||
file_path: input.filePath,
|
||||
content_sha256: hashContent(input.content),
|
||||
semantic_change: semanticChange,
|
||||
tool: input.toolName,
|
||||
}
|
||||
|
||||
const tracePath = path.join(input.workspaceRoot, AGENT_TRACE_PATH)
|
||||
await fs.mkdir(path.dirname(tracePath), { recursive: true })
|
||||
await fs.appendFile(tracePath, `${JSON.stringify(entry)}\n`, "utf8")
|
||||
}
|
||||
5
src/core/trace/ContentHasher.ts
Normal file
5
src/core/trace/ContentHasher.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createHash } from "crypto"
|
||||
|
||||
export function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content, "utf8").digest("hex")
|
||||
}
|
||||
30
src/core/trace/SemanticClassifier.ts
Normal file
30
src/core/trace/SemanticClassifier.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type SemanticChangeType = "REFACTOR" | "EVOLUTION"
|
||||
|
||||
const REFACTOR_PATTERN = /\b(refactor|rewrite|rename|restructure|cleanup|simplif(y|ication))\b/i
|
||||
|
||||
function toSearchText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => toSearchText(item)).join(" ")
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>)
|
||||
.map((item) => toSearchText(item))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function classifySemanticChange(input: {
|
||||
intentTitle?: unknown
|
||||
intentOperations?: unknown
|
||||
filePath?: string
|
||||
}): SemanticChangeType {
|
||||
const corpus = [input.intentTitle, input.intentOperations, input.filePath].map((v) => toSearchText(v)).join(" ")
|
||||
return REFACTOR_PATTERN.test(corpus) ? "REFACTOR" : "EVOLUTION"
|
||||
}
|
||||
|
|
@ -1196,16 +1196,29 @@ export class ClineProvider
|
|||
let localPort = "5173"
|
||||
|
||||
try {
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const portFilePath = path.resolve(__dirname, "../../.vite-port")
|
||||
const portFileCandidates = [
|
||||
path.resolve(__dirname, "../../.vite-port"),
|
||||
path.resolve(__dirname, "../../../.vite-port"),
|
||||
path.resolve(__dirname, "../../../../.vite-port"),
|
||||
]
|
||||
|
||||
if (fs.existsSync(portFilePath)) {
|
||||
localPort = fs.readFileSync(portFilePath, "utf8").trim()
|
||||
console.log(`[ClineProvider:Vite] Using Vite server port from ${portFilePath}: ${localPort}`)
|
||||
let resolvedPortFilePath: string | undefined
|
||||
for (const candidatePath of portFileCandidates) {
|
||||
try {
|
||||
await fs.access(candidatePath)
|
||||
resolvedPortFilePath = candidatePath
|
||||
break
|
||||
} catch {
|
||||
// Try next location.
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPortFilePath) {
|
||||
localPort = (await fs.readFile(resolvedPortFilePath, "utf8")).trim()
|
||||
console.log(`[ClineProvider:Vite] Using Vite server port from ${resolvedPortFilePath}: ${localPort}`)
|
||||
} else {
|
||||
console.log(
|
||||
`[ClineProvider:Vite] Port file not found at ${portFilePath}, using default port: ${localPort}`,
|
||||
`[ClineProvider:Vite] Port file not found in known locations, using default port: ${localPort}`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,28 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
// Initialize the provider *before* the Roo Code Cloud service.
|
||||
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService)
|
||||
|
||||
// Clear stale intent state on startup so governance checks don't pass due to
|
||||
// an old activeIntentId from a previous session/workspace state.
|
||||
try {
|
||||
const state = await provider.getState()
|
||||
const activeIntentId = String((state as any)?.activeIntentId ?? "").trim()
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|
||||
if (activeIntentId && workspaceRoot) {
|
||||
const { loadIntentContext } = await import("./core/intent/IntentContextLoader")
|
||||
const context = await loadIntentContext(workspaceRoot, activeIntentId).catch(() => null)
|
||||
|
||||
if (!context) {
|
||||
await provider.contextProxy.updateGlobalState("activeIntentId" as any, undefined as any)
|
||||
outputChannel.appendLine(`[Governance] Cleared stale activeIntentId: ${activeIntentId}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
outputChannel.appendLine(
|
||||
`[Governance] Failed stale activeIntentId check: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize Roo Code Cloud service.
|
||||
const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages()
|
||||
|
||||
|
|
|
|||
38
src/hooks/README.md
Normal file
38
src/hooks/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Hooks Directory - Clean Hook Interfaces
|
||||
|
||||
This directory contains the clean, composable hook implementations for the governed AI-native IDE.
|
||||
|
||||
## Pre-Hooks (Execution Phase)
|
||||
|
||||
| Hook | Phase | Purpose |
|
||||
| ------------------- | ------- | --------------------------------------------------- |
|
||||
| `validateIntent` | Phase 1 | Ensures active intent exists before execution |
|
||||
| `classifyCommand` | Phase 2 | Categorizes SAFE vs DESTRUCTIVE commands |
|
||||
| `enforceScope` | Phase 2 | Validates file against intent's owned_scope |
|
||||
| `authorizeHITL` | Phase 2 | Shows user approval dialog for destructive commands |
|
||||
| `loadIntentContext` | Phase 1 | Loads intent context from active_intents.yaml |
|
||||
|
||||
## Post-Hooks (Completion Phase)
|
||||
|
||||
| Hook | Phase | Purpose |
|
||||
| -------------------- | ------- | ----------------------------------------------- |
|
||||
| `hashContent` | Phase 3 | Generates SHA-256 content hash for traceability |
|
||||
| `classifyMutation` | Phase 3 | Determines REFACTOR vs EVOLUTION |
|
||||
| `recordTrace` | Phase 3 | Appends trace entry to agent_trace.jsonl |
|
||||
| `validateLock` | Phase 4 | Checks for stale files before write |
|
||||
| `captureInitialHash` | Phase 4 | Captures pre-write hash for optimistic locking |
|
||||
|
||||
## Integration Points
|
||||
|
||||
These hooks are wired into the following locations:
|
||||
|
||||
- **Pre-execution:** `src/core/assistant-message/presentAssistantMessage.ts`
|
||||
- **Post-execution:** `src/core/tools/WriteToFileTool.ts`
|
||||
- **Pre-prompt:** `src/core/task/Task.ts`
|
||||
|
||||
## Architecture Philosophy
|
||||
|
||||
- **Isolated:** Each hook is independent and focused
|
||||
- **Composable:** Hooks can be combined in different orders
|
||||
- **Fail-safe:** All hooks are wrapped in try/catch blocks
|
||||
- **Non-intrusive:** Added without modifying core logic
|
||||
3
src/hooks/index.ts
Normal file
3
src/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Main hooks export
|
||||
export * from "./pre-hooks"
|
||||
export * from "./post-hooks"
|
||||
30
src/hooks/intentPreflightHook.ts
Normal file
30
src/hooks/intentPreflightHook.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { INVALID_ACTIVE_INTENT_ERROR, loadIntentContext } from "../core/intent/IntentContextLoader"
|
||||
|
||||
import type { IntentHookContext, HookResult } from "./types"
|
||||
|
||||
export async function runIntentPreflightHook(context: IntentHookContext): Promise<HookResult> {
|
||||
const activeIntentId = String(context.activeIntentId ?? "").trim()
|
||||
if (!activeIntentId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: INVALID_ACTIVE_INTENT_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const intentContext = await loadIntentContext(context.workspaceRoot, activeIntentId)
|
||||
if (!intentContext) {
|
||||
return {
|
||||
ok: false,
|
||||
error: INVALID_ACTIVE_INTENT_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: INVALID_ACTIVE_INTENT_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/hooks/optimisticLockPreWriteHook.ts
Normal file
27
src/hooks/optimisticLockPreWriteHook.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { getCurrentHash, StaleFileError, validateLock } from "../core/concurrency/OptimisticLock"
|
||||
|
||||
import type { HookResult, OptimisticLockContext } from "./types"
|
||||
|
||||
export interface OptimisticLockResult extends HookResult {
|
||||
currentHash?: string
|
||||
}
|
||||
|
||||
export async function runOptimisticLockPreWriteHook(context: OptimisticLockContext): Promise<OptimisticLockResult> {
|
||||
const expectedHash = String(context.expectedHash ?? "").trim()
|
||||
if (!expectedHash) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const isValid = await validateLock(expectedHash, context.filePath)
|
||||
if (isValid) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const currentHash = await getCurrentHash(context.filePath).catch(() => "unavailable")
|
||||
const error = new StaleFileError(context.filePath, expectedHash, currentHash)
|
||||
return {
|
||||
ok: false,
|
||||
error: error.message,
|
||||
currentHash,
|
||||
}
|
||||
}
|
||||
29
src/hooks/post-hooks.ts
Normal file
29
src/hooks/post-hooks.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export * from "./types"
|
||||
export * from "./tracePostWriteHook"
|
||||
|
||||
import { hashContent as hashContentInternal } from "../core/trace/ContentHasher"
|
||||
import { classifySemanticChange } from "../core/trace/SemanticClassifier"
|
||||
import { appendAgentTrace, type AgentTraceAppendInput } from "../core/trace/AgentTraceSerializer"
|
||||
import { getCurrentHash, validateLock } from "../core/concurrency/OptimisticLock"
|
||||
|
||||
// Post-execution hooks that run after tools
|
||||
export const postHooks = {
|
||||
// Phase 3: Content hashing
|
||||
hashContent: (content: string) => hashContentInternal(content),
|
||||
|
||||
// Phase 3: Semantic classification
|
||||
classifyMutation: (intentId: string, filePath: string) =>
|
||||
classifySemanticChange({
|
||||
intentTitle: intentId,
|
||||
filePath,
|
||||
}),
|
||||
|
||||
// Phase 3: Trace recording
|
||||
recordTrace: async (entry: AgentTraceAppendInput) => appendAgentTrace(entry),
|
||||
|
||||
// Phase 4: Optimistic locking
|
||||
validateLock: async (expectedHash: string, filePath: string) => validateLock(expectedHash, filePath),
|
||||
|
||||
// Phase 4: Capture initial hash
|
||||
captureInitialHash: async (filePath: string) => getCurrentHash(filePath),
|
||||
}
|
||||
42
src/hooks/pre-hooks.ts
Normal file
42
src/hooks/pre-hooks.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export * from "./types"
|
||||
export * from "./intentPreflightHook"
|
||||
export * from "./optimisticLockPreWriteHook"
|
||||
|
||||
import type { ToolName } from "@roo-code/types"
|
||||
|
||||
import { classifyCommand } from "../core/governance/CommandClassifier"
|
||||
import { enforceIntentScope } from "../core/governance/ScopeEnforcer"
|
||||
import { showHITLApproval } from "../core/governance/HITLAuthorizer"
|
||||
import { loadIntentContext as loadIntentContextInternal } from "../core/intent/IntentContextLoader"
|
||||
|
||||
interface TaskLike {
|
||||
providerRef?: {
|
||||
deref?: () => { getState?: () => Promise<unknown> } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-execution hooks that run before tools
|
||||
export const preHooks = {
|
||||
// Phase 1: Intent validation
|
||||
validateIntent: async (task: TaskLike) => {
|
||||
const state = await task.providerRef?.deref?.()?.getState?.()
|
||||
return !!(state as any)?.activeIntentId
|
||||
},
|
||||
|
||||
// Phase 2: Command classification
|
||||
classifyCommand: (toolName: string) => classifyCommand(toolName as ToolName),
|
||||
|
||||
// Phase 2: Scope enforcement
|
||||
enforceScope: async (
|
||||
filePath: string,
|
||||
intentId: string,
|
||||
cwd: string,
|
||||
intentScope?: { files?: string[] } | unknown,
|
||||
) => enforceIntentScope(intentId, cwd, intentScope ?? {}, [filePath]),
|
||||
|
||||
// Phase 2: HITL authorization
|
||||
authorizeHITL: async (toolName: string, params: unknown) => showHITLApproval(toolName, params),
|
||||
|
||||
// Phase 1: Context loading
|
||||
loadIntentContext: async (intentId: string, cwd: string) => loadIntentContextInternal(cwd, intentId),
|
||||
}
|
||||
21
src/hooks/tracePostWriteHook.ts
Normal file
21
src/hooks/tracePostWriteHook.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { appendAgentTrace } from "../core/trace/AgentTraceSerializer"
|
||||
|
||||
import type { HookResult, TraceHookContext } from "./types"
|
||||
|
||||
export async function runTracePostWriteHook(context: TraceHookContext): Promise<HookResult> {
|
||||
try {
|
||||
await appendAgentTrace({
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
activeIntentId: context.activeIntentId,
|
||||
filePath: context.filePath,
|
||||
content: context.content,
|
||||
toolName: context.toolName,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/hooks/types.ts
Normal file
24
src/hooks/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export interface HookContext {
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface IntentHookContext extends HookContext {
|
||||
activeIntentId?: string
|
||||
}
|
||||
|
||||
export interface TraceHookContext extends HookContext {
|
||||
activeIntentId: string
|
||||
filePath: string
|
||||
content: string
|
||||
toolName: string
|
||||
}
|
||||
|
||||
export interface OptimisticLockContext extends HookContext {
|
||||
expectedHash?: string
|
||||
filePath: string
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ export const toolParamNames = [
|
|||
"todos",
|
||||
"prompt",
|
||||
"image",
|
||||
"intent_id",
|
||||
// read_file parameters (native protocol)
|
||||
"operations", // search_and_replace parameter for multiple operations
|
||||
"patch", // apply_patch parameter
|
||||
|
|
@ -113,6 +114,7 @@ export type NativeToolArgs = {
|
|||
skill: { skill: string; args?: string }
|
||||
search_files: { path: string; regex: string; file_pattern?: string | null }
|
||||
switch_mode: { mode_slug: string; reason: string }
|
||||
select_active_intent: { intent_id: string }
|
||||
update_todo_list: { todos: string }
|
||||
use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record<string, unknown> }
|
||||
write_to_file: { path: string; content: string }
|
||||
|
|
@ -283,6 +285,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
|||
ask_followup_question: "ask questions",
|
||||
attempt_completion: "complete tasks",
|
||||
switch_mode: "switch modes",
|
||||
select_active_intent: "load intent context",
|
||||
new_task: "create new task",
|
||||
codebase_search: "codebase search",
|
||||
update_todo_list: "update todo list",
|
||||
|
|
@ -308,7 +311,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
|
|||
tools: ["use_mcp_tool", "access_mcp_resource"],
|
||||
},
|
||||
modes: {
|
||||
tools: ["switch_mode", "new_task"],
|
||||
tools: ["switch_mode", "select_active_intent", "new_task"],
|
||||
alwaysAvailable: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -318,6 +321,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [
|
|||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
"switch_mode",
|
||||
"select_active_intent",
|
||||
"new_task",
|
||||
"update_todo_list",
|
||||
"run_slash_command",
|
||||
|
|
|
|||
1
test.txt
Normal file
1
test.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hello World
|
||||
0
testinig.txt
Normal file
0
testinig.txt
Normal file
0
testtt.txt
Normal file
0
testtt.txt
Normal file
1
unauthorized_test.txt
Normal file
1
unauthorized_test.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
This should not work
|
||||
Loading…
Add table
Reference in a new issue