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