From dedb07ed426adb4a15f1089bf00ae6c89142ac29 Mon Sep 17 00:00:00 2001 From: Sumeyaaaa Date: Wed, 18 Feb 2026 14:01:09 +0300 Subject: [PATCH] feat: implement intent-governed HookEngine and orchestration data model - Add HookEngine middleware with pre/post-hooks for tool governance - Implement OrchestrationDataModel for .orchestration/ directory management - Create select_active_intent tool enforcing Reasoning Loop protocol - Integrate hooks into presentAssistantMessage for all destructive tools - Add UI-blocking authorization (HITL) for intent evolution - Implement scope enforcement and trace logging with content hashing --- .orchestration/AGENT.md | 36 ++ .orchestration/active_intents.yaml | 222 ++++++++++++ .orchestration/agent_trace.jsonl | 7 + .orchestration/intent_map.md | 19 + packages/types/src/tool.ts | 1 + .../presentAssistantMessage.ts | 60 +++- src/core/hooks/HookEngine.ts | 339 ++++++++++++++++++ .../orchestration/OrchestrationDataModel.ts | 259 +++++++++++++ .../prompts/sections/tool-use-guidelines.ts | 22 ++ src/core/prompts/tools/native-tools/index.ts | 2 + .../native-tools/select_active_intent.ts | 27 ++ src/core/tools/SelectActiveIntentTool.ts | 128 +++++++ src/shared/tools.ts | 2 + 13 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 .orchestration/AGENT.md create mode 100644 .orchestration/active_intents.yaml create mode 100644 .orchestration/agent_trace.jsonl create mode 100644 .orchestration/intent_map.md create mode 100644 src/core/hooks/HookEngine.ts create mode 100644 src/core/orchestration/OrchestrationDataModel.ts create mode 100644 src/core/prompts/tools/native-tools/select_active_intent.ts create mode 100644 src/core/tools/SelectActiveIntentTool.ts diff --git a/.orchestration/AGENT.md b/.orchestration/AGENT.md new file mode 100644 index 0000000000..be54ed4bb1 --- /dev/null +++ b/.orchestration/AGENT.md @@ -0,0 +1,36 @@ +# Shared Knowledge Base + +This file contains persistent knowledge shared across parallel sessions (Architect/Builder/Tester). Contains "Lessons Learned" and project-specific stylistic rules. + +## Lessons Learned + + + +## Project-Specific Rules + + + +## Architectural Decisions + + + diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 0000000000..de031ef3b0 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,222 @@ +active_intents: + - id: INT-001 + name: INT-001 — Intent-Code Traceability (Spec) + status: IN_PROGRESS + owned_scope: + - src/core/assistant-message/** + - src/core/tools/** + - src/core/hooks/** + - src/core/orchestration/** + - src/core/prompts/** + - .orchestration/** + constraints: + - Must enforce **intent selection before any destructive tool** + (`write_to_file`, `edit_file`, `apply_diff`, etc.). + - "Must keep **privilege separation**: UI emits events; extension host + executes privileged actions; hooks are middleware." + - Must log **spatially independent** traces via content hashing. + acceptance_criteria: + - Agent cannot write code before calling `select_active_intent(intent_id)`. + - "When a file is written, a JSONL entry is appended to + `.orchestration/agent_trace.jsonl` that includes:" + - intent id + - file path + - line range (best-effort) + - "`sha256:` content hash of the modified block" + - "`.orchestration/active_intents.yaml` exists and contains this intent." + created_at: 2026-02-18T08:56:57.063Z + updated_at: 2026-02-18T08:56:57.086Z + spec_hash: sha256:7966563e9a7886587d3c421761708195d8a1ce21addd4632f560965411fd839b + spec_file: specs/INT-001-intent-code-traceability.md + - id: INT-002 + name: INT-002 — Hook System Implementation + status: IN_PROGRESS + owned_scope: + - src/core/hooks/** + - src/core/assistant-message/presentAssistantMessage.ts + - src/core/tools/** + - .orchestration/** + constraints: + - Must integrate with existing `presentAssistantMessage()` function + without breaking current tool execution flow. + - Pre-hooks must run **before** `tool.handle()` is called. + - Post-hooks must run **after** `tool.execute()` completes but before + result is returned. + - Hook system must be non-blocking for non-destructive tools (read-only + operations). + - Must maintain backward compatibility with existing tools. + acceptance_criteria: + - "`HookEngine` class exists in `src/core/hooks/HookEngine.ts`." + - Pre-hook validates intent selection for destructive tools + (`write_to_file`, `edit_file`, `execute_command`, etc.). + - Pre-hook enforces scope validation (file path must be within intent's + `owned_scope`). + - Post-hook logs trace entries to `.orchestration/agent_trace.jsonl` for + mutating actions. + - "`presentAssistantMessage()` integrates `HookEngine` with Pre-Hook and + Post-Hook calls." + - All existing tests pass after hook integration. + created_at: 2026-02-18T08:56:57.094Z + updated_at: 2026-02-18T08:56:57.094Z + spec_hash: sha256:e10e923c607996643684be16016bc8305d7259b543eae623e92b5e49db8b902c + spec_file: specs/INT-002-hook-system-implementation.md + - id: INT-003 + name: INT-003 — Two-Stage Reasoning Loop + status: IN_PROGRESS + owned_scope: + - src/core/hooks/HookEngine.ts + - src/core/prompts/sections/tool-use-guidelines.ts + - src/core/tools/SelectActiveIntentTool.ts + - src/core/task/Task.ts + constraints: + - "**Stage 1 (Reasoning Intercept):** Agent MUST call + `select_active_intent(intent_id)` before any destructive tool." + - "**Stage 2 (Contextualized Action):** Agent receives intent context and + must include it when making code changes." + - System prompt must enforce this protocol in tool-use guidelines. + - Intent context must be injected into the agent's context before code + generation. + acceptance_criteria: + - System prompt includes instructions requiring `select_active_intent` + before code changes. + - "`SelectActiveIntentTool` returns XML `` block with + scope, constraints, and acceptance criteria." + - Pre-hook blocks destructive tools if no active intent is selected. + - Agent receives intent context in subsequent tool calls. + - Intent context is logged in `agent_trace.jsonl` entries. + created_at: 2026-02-18T08:56:57.098Z + updated_at: 2026-02-18T08:56:57.098Z + spec_hash: sha256:a75817698c5b1479c68e43dd41b04752d3a6df7c4af71f3ab60fdedf705e4dda + spec_file: specs/INT-003-reasoning-loop.md + - id: INT-004 + name: INT-004 — Orchestration Directory Management + status: IN_PROGRESS + owned_scope: + - src/core/orchestration/OrchestrationDataModel.ts + - .orchestration/active_intents.yaml + - .orchestration/agent_trace.jsonl + - .orchestration/intent_map.md + - .orchestration/AGENT.md + constraints: + - "`.orchestration/` directory must be machine-managed (not user-edited + directly)." + - "`active_intents.yaml` must be valid YAML and follow the schema defined + in `document.md`." + - "`agent_trace.jsonl` must be append-only (no modifications, only + appends)." + - All file operations must be atomic (write to temp file, then rename). + - Directory and files must be initialized on first use. + acceptance_criteria: + - "`OrchestrationDataModel` class provides methods:" + - "`initialize()`: Creates directory and initializes files if missing." + - "`readActiveIntents()`: Parses and returns active intents." + - "`appendAgentTrace()`: Appends trace entry to JSONL file." + - "`updateIntentMap()`: Updates intent-to-file mapping." + - "`appendAgentKnowledge()`: Appends to AGENT.md." + - All methods handle errors gracefully and log failures. + - Files are created with proper templates if missing. + - YAML parsing validates schema and reports errors clearly. + created_at: 2026-02-18T08:56:57.099Z + updated_at: 2026-02-18T08:56:57.099Z + spec_hash: sha256:659bd435cecc3223171c3fce81f671c26baac9ce354c9a6e3c967164983ed9fb + spec_file: specs/INT-004-orchestration-directory.md + - id: INT-005 + name: INT-005 — Logging & Traceability + status: IN_PROGRESS + owned_scope: + - src/core/hooks/HookEngine.ts` (Post-Hook implementation) + - src/core/orchestration/OrchestrationDataModel.ts + - .orchestration/agent_trace.jsonl + - src/utils/git.ts` (for VCS revision tracking) + constraints: + - Trace entries must include `sha256:` content hash of modified code + blocks. + - Line ranges must be best-effort (may be approximate for complex edits). + - "Each trace entry must link to:" + - Intent ID + - File path (relative to workspace root) + - VCS revision (Git SHA) + - Timestamp + - Model identifier + - Content hashing must be spatially independent (same code block = same + hash regardless of file location). + acceptance_criteria: + - Post-hook computes SHA-256 hash of modified content for file tools. + - "Trace entry includes all required fields per `document.md` schema:" + - "`id` (UUID)" + - "`timestamp` (ISO 8601)" + - "`vcs.revision_id` (Git SHA)" + - "`files[]` with `relative_path`, `conversations[]`, `ranges[]`, + `content_hash`" + - Trace entries are appended atomically to `agent_trace.jsonl`. + - "Content hash format: `sha256:`." + - Git SHA is retrieved from workspace root (handles non-Git repos + gracefully). + created_at: 2026-02-18T08:56:57.099Z + updated_at: 2026-02-18T08:56:57.099Z + spec_hash: sha256:2b3421a22c9e27a817e27aea652f3332cdbc821338b52e112ff654ff88c36317 + spec_file: specs/INT-005-logging-traceability.md + - id: INT-006 + name: INT-006 — Testing & Validation + status: IN_PROGRESS + owned_scope: + - src/core/hooks/**/*.test.ts + - src/core/orchestration/**/*.test.ts + - src/core/tools/SelectActiveIntentTool.test.ts + - tests/integration/hook-system.test.ts + - tests/e2e/intent-traceability.test.ts + constraints: + - Tests must not modify production `.orchestration/` files (use temp + directories). + - Tests must be deterministic and isolated (no shared state). + - Integration tests must verify hook system works with real tool execution. + - E2E tests must simulate full agent workflow (intent selection → code + change → trace logging). + acceptance_criteria: + - Unit tests for `HookEngine.preHook()` and `HookEngine.postHook()`. + - Unit tests for `OrchestrationDataModel` file operations. + - Unit tests for `SelectActiveIntentTool` intent loading and context + generation. + - "Integration test: Verify Pre-Hook blocks destructive tool without + intent." + - "Integration test: Verify Post-Hook logs trace entry after file write." + - "E2E test: Full workflow from intent selection to trace logging." + - All tests pass in CI/CD pipeline. + - Test coverage > 80% for hook and orchestration modules. + created_at: 2026-02-18T08:56:57.099Z + updated_at: 2026-02-18T08:56:57.100Z + spec_hash: sha256:f8343d2839370244e2f1d7b6622494a96438c86b21f42b98b159eb34e33a59c6 + spec_file: specs/INT-006-testing-validation.md + - id: INT-007 + name: INT-007 — Documentation & Knowledge Base + status: IN_PROGRESS + owned_scope: + - ARCHITECTURE_NOTES.md + - README.md` (Intent-Code Traceability section) + - .orchestration/AGENT.md + - docs/intent-traceability/ + - CHANGELOG.md` (relevant entries) + constraints: + - "`ARCHITECTURE_NOTES.md` must document all injection points and hook + integration." + - '`AGENT.md` must be append-only knowledge base for "Lessons Learned".' + - Documentation must be kept in sync with code changes. + - API documentation must include examples for each public method. + acceptance_criteria: + - "`ARCHITECTURE_NOTES.md` includes:" + - Tool execution flow diagram + - Hook injection points with line numbers + - System prompt modification points + - Data model schemas + - "`AGENT.md` includes:" + - Lessons learned from implementation + - Common pitfalls and solutions + - Performance optimizations + - Stylistic rules for intent specifications + - README includes setup instructions and usage examples. + - All public APIs are documented with JSDoc comments. + - Documentation is reviewed and updated with each major change. + created_at: 2026-02-18T08:56:57.100Z + updated_at: 2026-02-18T08:56:57.100Z + spec_hash: sha256:50566c7bdbddce41c4f739cab9631172321f2880e8cb7a35fdf8c0d1a1aa56b4 + spec_file: specs/INT-007-documentation.md diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 0000000000..f831a22779 --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1,7 @@ +# Agent Trace Ledger (JSONL format - one JSON object per line) +# Append-only, machine-readable history of every mutating action. +# Links abstract Intent to concrete Code Hash for spatial independence. +# +# Example entry: +# {"id":"trace-1234567890-abc","timestamp":"2026-02-16T12:00:00Z","vcs":{"revision_id":"abc123def456"},"files":[{"relative_path":"src/auth/middleware.ts","conversations":[{"url":"task-xyz","contributor":{"entity_type":"AI","model_identifier":"claude-3-5-sonnet"},"ranges":[{"start_line":15,"end_line":45,"content_hash":"sha256:a8f5f167f44f4964e6c998dee827110c"}],"related":[{"type":"intent","value":"INT-001"}]}]}]} + diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 0000000000..df107baa86 --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,19 @@ +# Intent Map + +This file maps high-level business intents to physical files and AST nodes. When a manager asks, "Where is the billing logic?", this file provides the answer. + +## Intents + + + diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..3655a5f29e 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,7 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + "select_active_intent", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..708cae5f29 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,9 +37,11 @@ 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" +import { HookEngine } from "../hooks/HookEngine" /** * Processes and presents assistant message content to the user interface. @@ -675,15 +677,45 @@ export async function presentAssistantMessage(cline: Task) { } } + // Initialize hook engine for this task + const hookEngine = new HookEngine(cline.cwd) + await hookEngine.initialize() + + // Pre-Hook: Intercept tool execution + const preHookResult = await hookEngine.preHook(block.name as ToolName, block, cline) + if (!preHookResult.shouldProceed) { + pushToolResult(formatResponse.toolError(preHookResult.errorMessage || "Tool execution blocked by hook")) + break + } + switch (block.name) { - case "write_to_file": - await checkpointSaveAndMark(cline) - await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + 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) + let writeSuccess = false + let writeResult: string | undefined + try { + await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + askApproval, + handleError, + pushToolResult: (result) => { + writeResult = typeof result === "string" ? result : JSON.stringify(result) + pushToolResult(result) + }, + }) + writeSuccess = true + } catch (error) { + writeSuccess = false + } + // Post-Hook: Log trace entry + await hookEngine.postHook(block.name as ToolName, block, cline, writeSuccess, writeResult) + break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, @@ -718,11 +750,23 @@ export async function presentAssistantMessage(cline: Task) { break case "edit_file": await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { - askApproval, - handleError, - pushToolResult, - }) + let editSuccess = false + let editResult: string | undefined + try { + await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + askApproval, + handleError, + pushToolResult: (result) => { + editResult = typeof result === "string" ? result : JSON.stringify(result) + pushToolResult(result) + }, + }) + editSuccess = true + } catch (error) { + editSuccess = false + } + // Post-Hook: Log trace entry + await hookEngine.postHook(block.name as ToolName, block, cline, editSuccess, editResult) break case "apply_patch": await checkpointSaveAndMark(cline) diff --git a/src/core/hooks/HookEngine.ts b/src/core/hooks/HookEngine.ts new file mode 100644 index 0000000000..99fdf9aa06 --- /dev/null +++ b/src/core/hooks/HookEngine.ts @@ -0,0 +1,339 @@ +import { Task } from "../task/Task" +import type { ToolUse, ToolName } from "../../shared/tools" +import { OrchestrationDataModel, type ActiveIntent } from "../orchestration/OrchestrationDataModel" +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import { execSync } from "child_process" + +/** + * Hook execution result + */ +export interface HookResult { + shouldProceed: boolean + errorMessage?: string + injectedContext?: string +} + +/** + * Hook Engine - Middleware boundary for tool execution + * Implements Pre-Hook and Post-Hook interception + */ +export class HookEngine { + private dataModel: OrchestrationDataModel + + constructor(workspaceRoot: string) { + this.dataModel = new OrchestrationDataModel(workspaceRoot) + } + + /** + * UI-blocking authorization (HITL): pause execution until the user approves/rejects. + * + * We intentionally make this a modal dialog to "pause the Promise chain" as required by `document.md`. + * To avoid excessive prompts, we authorize once per intent per task session. + */ + private async ensureIntentAuthorized( + task: Task, + intent: ActiveIntent, + toolName: ToolName, + details?: { filePath?: string; command?: string }, + ): Promise<{ approved: boolean; message?: string }> { + const approvedSetKey = "__approvedIntentIds" as const + const approved = ((task as any)[approvedSetKey] ?? []) as string[] + + // Authorize once per intent per task session. + if (approved.includes(intent.id)) { + return { approved: true } + } + + const detailLines: string[] = [] + if (details?.filePath) detailLines.push(`File: ${details.filePath}`) + if (details?.command) detailLines.push(`Command: ${details.command}`) + if (detailLines.length === 0) detailLines.push(`Tool: ${toolName}`) + + const message = + `Approve intent evolution for this task?\n\n` + + `Intent: ${intent.id} — ${intent.name}\n` + + detailLines.join("\n") + + `\n\nThis will allow destructive actions under this intent for the current task session.` + + const answer = await vscode.window.showWarningMessage(message, { modal: true }, "Approve", "Reject") + + if (answer !== "Approve") { + return { approved: false, message: "User rejected the intent evolution request." } + } + + ;(task as any)[approvedSetKey] = [...approved, intent.id] + return { approved: true } + } + + /** + * Initialize orchestration directory + */ + async initialize(): Promise { + await this.dataModel.initialize() + } + + /** + * Pre-Hook: Intercept tool execution before it happens + * Enforces intent context injection and scope validation + */ + async preHook(toolName: ToolName, toolUse: ToolUse, task: Task): Promise { + // Check if this is select_active_intent - allow it through + if (toolName === "select_active_intent") { + return { shouldProceed: true } + } + + // For all other tools, check if active intent is set + const activeIntentId = (task as any).activeIntentId as string | undefined + let activeIntent = (task as any).activeIntent as ActiveIntent | undefined + + // Destructive tools require intent selection + const destructiveTools: ToolName[] = [ + "write_to_file", + "edit_file", + "apply_diff", + "apply_patch", + "edit", + "search_replace", + "search_and_replace", + "execute_command", + ] + + if (destructiveTools.includes(toolName)) { + if (!activeIntentId) { + return { + shouldProceed: false, + errorMessage: + "You must cite a valid active Intent ID. Call select_active_intent(intent_id) before making code changes.", + } + } + + // Load intent details (for authorization prompt + scope checks) + if (!activeIntent) { + activeIntent = await this.dataModel.getIntent(activeIntentId) + if (activeIntent) { + ;(task as any).activeIntent = activeIntent + } + } + + if (!activeIntent) { + return { + shouldProceed: false, + errorMessage: `Intent "${activeIntentId}" not found in active_intents.yaml. Please select a valid intent ID.`, + } + } + + // Validate scope for write operations + if (toolName === "write_to_file" || toolName === "edit_file") { + const filePath = (toolUse.params as any).path as string | undefined + if (filePath) { + const scopeValid = await this.validateScope(activeIntentId, filePath, task.cwd) + if (!scopeValid.valid) { + return { + shouldProceed: false, + errorMessage: `Scope Violation: ${activeIntentId} is not authorized to edit ${filePath}. ${scopeValid.message}`, + } + } + + // UI-blocking authorization (HITL) + const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName, { filePath }) + if (!auth.approved) { + return { + shouldProceed: false, + errorMessage: auth.message || "Operation rejected by user.", + } + } + } + } else if (toolName === "execute_command") { + const command = (toolUse.params as any).command as string | undefined + const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName, { command }) + if (!auth.approved) { + return { + shouldProceed: false, + errorMessage: auth.message || "Operation rejected by user.", + } + } + } else { + // Other destructive tools: still require UI-blocking authorization. + const auth = await this.ensureIntentAuthorized(task, activeIntent, toolName) + if (!auth.approved) { + return { + shouldProceed: false, + errorMessage: auth.message || "Operation rejected by user.", + } + } + } + } + + return { shouldProceed: true } + } + + /** + * Post-Hook: Execute after tool completes + * Updates trace logs and intent state + */ + async postHook(toolName: ToolName, toolUse: ToolUse, task: Task, success: boolean, result?: string): Promise { + const activeIntentId = (task as any).activeIntentId as string | undefined + + // Only log destructive operations + const destructiveTools: ToolName[] = [ + "write_to_file", + "edit_file", + "apply_diff", + "apply_patch", + "edit", + "search_replace", + "search_and_replace", + ] + + if (destructiveTools.includes(toolName) && activeIntentId && success) { + await this.logTraceEntry(toolName, toolUse, task, activeIntentId, result) + } + } + + /** + * Validate that a file path is within the intent's owned scope + */ + private async validateScope( + intentId: string, + filePath: string, + workspaceRoot: string, + ): Promise<{ valid: boolean; message?: string }> { + try { + const intent = await this.dataModel.getIntent(intentId) + if (!intent) { + return { valid: false, message: "Intent not found" } + } + + const normalizedPath = path.normalize(filePath) + const absolutePath = path.resolve(workspaceRoot, normalizedPath) + + // Check if file matches any scope pattern + for (const scopePattern of intent.owned_scope) { + // Simple glob matching (can be enhanced with minimatch later) + if (this.matchesPattern(normalizedPath, scopePattern)) { + return { valid: true } + } + } + + return { + valid: false, + message: `File is outside intent scope. Request scope expansion or use a different intent.`, + } + } catch (error) { + console.error("Scope validation error:", error) + return { valid: true } // Fail open on error + } + } + + /** + * Simple pattern matching (supports ** and *) + */ + private matchesPattern(filePath: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\//g, "\\/") + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(filePath) + } + + /** + * Log trace entry to agent_trace.jsonl + */ + private async logTraceEntry( + toolName: ToolName, + toolUse: ToolUse, + task: Task, + intentId: string, + result?: string, + ): Promise { + try { + // Get current git revision + let gitRevision = "unknown" + try { + gitRevision = execSync("git rev-parse HEAD", { cwd: task.cwd, encoding: "utf-8" }).trim() + } catch { + // Git not available or not a git repo + } + + // Extract file path and content from tool params + const params = toolUse.params as any + const filePath = params.path as string | undefined + + if (!filePath) { + return // Can't log without file path + } + + // Read file content to compute hash + const absolutePath = path.resolve(task.cwd, filePath) + let fileContent = "" + let startLine = 1 + let endLine = 1 + + try { + fileContent = await fs.readFile(absolutePath, "utf-8") + const lines = fileContent.split("\n") + endLine = lines.length + + // If we have line numbers in params, use them + if (params.start_line !== undefined && params.end_line !== undefined) { + startLine = params.start_line + endLine = params.end_line + } + } catch { + // File doesn't exist or can't be read + return + } + + // Extract relevant code block + const lines = fileContent.split("\n") + const relevantLines = lines.slice(Math.max(0, startLine - 1), endLine) + const codeBlock = relevantLines.join("\n") + const contentHash = this.dataModel.computeContentHash(codeBlock) + + // Get model identifier from task + const modelId = task.api.getModel().id + + // Build trace entry + const traceEntry = { + id: `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + vcs: { + revision_id: gitRevision, + }, + files: [ + { + relative_path: filePath, + conversations: [ + { + url: `task-${task.taskId}`, + contributor: { + entity_type: "AI" as const, + model_identifier: modelId, + }, + ranges: [ + { + start_line: startLine, + end_line: endLine, + content_hash: `sha256:${contentHash}`, + }, + ], + related: [ + { + type: "intent" as const, + value: intentId, + }, + ], + }, + ], + }, + ], + } + + await this.dataModel.appendTraceEntry(traceEntry) + } catch (error) { + console.error("Failed to log trace entry:", error) + // Don't throw - logging failures shouldn't break tool execution + } + } +} diff --git a/src/core/orchestration/OrchestrationDataModel.ts b/src/core/orchestration/OrchestrationDataModel.ts new file mode 100644 index 0000000000..f6ab3053b8 --- /dev/null +++ b/src/core/orchestration/OrchestrationDataModel.ts @@ -0,0 +1,259 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as crypto from "crypto" +import * as yaml from "yaml" + +/** + * Intent specification structure matching the architecture spec + */ +export interface ActiveIntent { + id: string + name: string + status: "TODO" | "IN_PROGRESS" | "DONE" | "BLOCKED" + owned_scope: string[] + constraints: string[] + acceptance_criteria: string[] + created_at?: string + updated_at?: string +} + +export interface ActiveIntentsData { + active_intents: ActiveIntent[] +} + +/** + * Agent trace entry structure matching the architecture spec + */ +export interface AgentTraceRange { + start_line: number + end_line: number + content_hash: string +} + +export interface AgentTraceConversation { + url: string + contributor: { + entity_type: "AI" | "HUMAN" + model_identifier?: string + } + ranges: AgentTraceRange[] + related: Array<{ + type: "specification" | "intent" | "requirement" + value: string + }> +} + +export interface AgentTraceFile { + relative_path: string + conversations: AgentTraceConversation[] +} + +export interface AgentTraceEntry { + id: string + timestamp: string + vcs: { + revision_id: string + } + files: AgentTraceFile[] +} + +/** + * Orchestration Data Model + * Manages the .orchestration/ directory and its files + */ +export class OrchestrationDataModel { + private orchestrationDir: string + + constructor(workspaceRoot: string) { + this.orchestrationDir = path.join(workspaceRoot, ".orchestration") + } + + /** + * Initialize the .orchestration/ directory structure + */ + async initialize(): Promise { + try { + await fs.mkdir(this.orchestrationDir, { recursive: true }) + + // Initialize active_intents.yaml if it doesn't exist + const intentsPath = path.join(this.orchestrationDir, "active_intents.yaml") + try { + await fs.access(intentsPath) + } catch { + // File doesn't exist, create it + const initialData: ActiveIntentsData = { active_intents: [] } + await fs.writeFile(intentsPath, yaml.stringify(initialData), "utf-8") + } + + // Initialize agent_trace.jsonl if it doesn't exist + const tracePath = path.join(this.orchestrationDir, "agent_trace.jsonl") + try { + await fs.access(tracePath) + } catch { + // File doesn't exist, create empty file + await fs.writeFile(tracePath, "", "utf-8") + } + + // Initialize intent_map.md if it doesn't exist + const mapPath = path.join(this.orchestrationDir, "intent_map.md") + try { + await fs.access(mapPath) + } catch { + // File doesn't exist, create it with header + const header = `# Intent Map + +This file maps high-level business intents to physical files and AST nodes. + +## Intents + +` + await fs.writeFile(mapPath, header, "utf-8") + } + + // Initialize AGENT.md if it doesn't exist + const agentPath = path.join(this.orchestrationDir, "AGENT.md") + try { + await fs.access(agentPath) + } catch { + // File doesn't exist, create it with header + const header = `# Shared Knowledge Base + +This file contains persistent knowledge shared across parallel sessions (Architect/Builder/Tester). + +## Lessons Learned + +` + await fs.writeFile(agentPath, header, "utf-8") + } + } catch (error) { + console.error("Failed to initialize orchestration directory:", error) + throw error + } + } + + /** + * Read active intents from YAML file + */ + async readActiveIntents(): Promise { + const intentsPath = path.join(this.orchestrationDir, "active_intents.yaml") + try { + const content = await fs.readFile(intentsPath, "utf-8") + return yaml.parse(content) as ActiveIntentsData + } catch (error) { + console.error("Failed to read active_intents.yaml:", error) + return { active_intents: [] } + } + } + + /** + * Write active intents to YAML file + */ + async writeActiveIntents(data: ActiveIntentsData): Promise { + const intentsPath = path.join(this.orchestrationDir, "active_intents.yaml") + await fs.writeFile(intentsPath, yaml.stringify(data), "utf-8") + } + + /** + * Get a specific intent by ID + */ + async getIntent(intentId: string): Promise { + const data = await this.readActiveIntents() + return data.active_intents.find((intent) => intent.id === intentId) || null + } + + /** + * Update an intent (create if doesn't exist) + */ + async updateIntent(intent: ActiveIntent): Promise { + const data = await this.readActiveIntents() + const index = data.active_intents.findIndex((i) => i.id === intent.id) + + intent.updated_at = new Date().toISOString() + if (!intent.created_at) { + intent.created_at = intent.updated_at + } + + if (index >= 0) { + data.active_intents[index] = intent + } else { + data.active_intents.push(intent) + } + + await this.writeActiveIntents(data) + } + + /** + * Append a trace entry to agent_trace.jsonl + */ + async appendTraceEntry(entry: AgentTraceEntry): Promise { + const tracePath = path.join(this.orchestrationDir, "agent_trace.jsonl") + const line = JSON.stringify(entry) + "\n" + await fs.appendFile(tracePath, line, "utf-8") + } + + /** + * Get recent trace entries for a specific intent ID + * Returns the most recent entries (up to limit) that reference this intent + * This is used for Phase 1: Context Loader to provide recent history + */ + async getTraceEntriesForIntent(intentId: string, limit: number = 10): Promise { + const tracePath = path.join(this.orchestrationDir, "agent_trace.jsonl") + + try { + const content = await fs.readFile(tracePath, "utf-8") + const lines = content.trim().split("\n").filter((line) => line.trim() && !line.startsWith("#")) + + const entries: AgentTraceEntry[] = [] + + // Parse each line and filter by intent ID + for (const line of lines) { + try { + const entry = JSON.parse(line) as AgentTraceEntry + + // Check if any file's conversation references this intent + const referencesIntent = entry.files.some((file) => + file.conversations.some((conv) => + conv.related.some( + (rel) => rel.type === "intent" && rel.value === intentId, + ), + ), + ) + + if (referencesIntent) { + entries.push(entry) + } + } catch (error) { + // Skip invalid JSON lines (comments, etc.) + continue + } + } + + // Sort by timestamp (most recent first) and return up to limit + entries.sort((a, b) => { + const timeA = new Date(a.timestamp).getTime() + const timeB = new Date(b.timestamp).getTime() + return timeB - timeA // Descending order (newest first) + }) + + return entries.slice(0, limit) + } catch (error) { + // File doesn't exist or can't be read - return empty array + console.error("Failed to read agent_trace.jsonl:", error) + return [] + } + } + + /** + * Compute SHA-256 hash of content for spatial independence + */ + computeContentHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex") + } + + /** + * Get orchestration directory path + */ + getOrchestrationDir(): string { + return this.orchestrationDir + } +} diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 78193372cc..131595ba42 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,6 +1,28 @@ export function getToolUseGuidelinesSection(): string { return `# Tool Use Guidelines +## Intent-Driven Architecture (Reasoning Loop) + +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(intent_id) to load the necessary context. + +**CRITICAL PROTOCOL:** +1. When the user requests code changes (refactoring, new features, bug fixes), you MUST first: + - Analyze the request to identify which intent it relates to + - Call select_active_intent(intent_id) with a valid intent ID from active_intents.yaml + - Wait for the intent context to be loaded + - Only then proceed with code changes + +2. You CANNOT use write_to_file, edit_file, apply_diff, or any other code modification tools without first calling select_active_intent. + +3. If you attempt to write code without selecting an intent, the system will block your action and return an error. + +4. The intent context will provide you with: + - Owned scope (which files/directories you can modify) + - Constraints (rules you must follow) + - Acceptance criteria (definition of done) + +## General Tool Use + 1. Assess what information you already have and what information you need to proceed with the task. 2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like \`ls\` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. 3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..82915152b2 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -20,6 +20,7 @@ import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" +import selectActiveIntent from "./select_active_intent" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -47,6 +48,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch } return [ + selectActiveIntent, // Must be first - required before code changes accessMcpResource, apply_diff, applyPatch, diff --git a/src/core/prompts/tools/native-tools/select_active_intent.ts b/src/core/prompts/tools/native-tools/select_active_intent.ts new file mode 100644 index 0000000000..86d1f29bf6 --- /dev/null +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -0,0 +1,27 @@ +import type OpenAI from "openai" + +/** + * Tool for selecting an active intent before making code changes. + * This enforces the Reasoning Loop protocol. + */ +const selectActiveIntent: OpenAI.Chat.ChatCompletionFunctionTool = { + type: "function", + function: { + name: "select_active_intent", + description: + "Select an active intent from active_intents.yaml before making code changes. This is REQUIRED before using any code modification tools (write_to_file, edit_file, etc.). The intent provides context about scope, constraints, and acceptance criteria.", + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: + "The ID of the intent to activate (e.g., 'INT-001'). Must exist in .orchestration/active_intents.yaml", + }, + }, + required: ["intent_id"], + }, + }, +} + +export default selectActiveIntent diff --git a/src/core/tools/SelectActiveIntentTool.ts b/src/core/tools/SelectActiveIntentTool.ts new file mode 100644 index 0000000000..59105fd682 --- /dev/null +++ b/src/core/tools/SelectActiveIntentTool.ts @@ -0,0 +1,128 @@ +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" +import { OrchestrationDataModel } from "../orchestration/OrchestrationDataModel" + +interface SelectActiveIntentParams { + intent_id: string +} + +/** + * Tool for selecting an active intent before code changes. + * This enforces the Reasoning Loop: agents must select an intent before writing code. + */ +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { + readonly name = "select_active_intent" as const + + async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { intent_id } = params + const { pushToolResult, handleError } = callbacks + + try { + if (!intent_id) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + pushToolResult(await task.sayAndCreateMissingParamError("select_active_intent", "intent_id")) + return + } + + // Initialize orchestration data model + const dataModel = new OrchestrationDataModel(task.cwd) + await dataModel.initialize() + + // Load the intent from active_intents.yaml + const intent = await dataModel.getIntent(intent_id) + + if (!intent) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + pushToolResult( + formatResponse.toolError( + `Intent "${intent_id}" not found in active_intents.yaml. Please use a valid intent ID.`, + ), + ) + return + } + + // Get recent trace entries for this intent (Phase 1 requirement: Context Loader) + // This provides recent history to help the agent understand what has been done + const traceEntries = await dataModel.getTraceEntriesForIntent(intent_id, 5) + + // Store active intent in task instance + ;(task as any).activeIntentId = intent_id + ;(task as any).activeIntent = intent + + // Build context XML block for injection into prompt (now includes trace entries) + const contextXml = this.buildIntentContextXml(intent, traceEntries) + + // Reset mistake count on success + task.consecutiveMistakeCount = 0 + + // Return context as tool result (will be injected into next prompt) + pushToolResult(contextXml) + + return + } catch (error) { + await handleError("selecting active intent", error as Error) + return + } + } + + /** + * Build XML block containing intent context for prompt injection + * Now includes recent trace entries for context (Phase 1: Context Loader) + */ + private buildIntentContextXml(intent: any, traceEntries: any[] = []): string { + const scopeList = intent.owned_scope.map((s: string) => ` - ${s}`).join("\n") + const constraintsList = intent.constraints.map((c: string) => ` - ${c}`).join("\n") + const criteriaList = intent.acceptance_criteria.map((c: string) => ` - ${c}`).join("\n") + + // Build recent history section from trace entries + let recentHistorySection = "" + if (traceEntries.length > 0) { + const historyItems = traceEntries.map((entry) => { + const files = entry.files + .map((f: any) => { + const ranges = f.conversations[0]?.ranges?.[0] + if (ranges) { + return ` - ${f.relative_path} (lines ${ranges.start_line}-${ranges.end_line})` + } + return ` - ${f.relative_path}` + }) + .join("\n") + const timestamp = new Date(entry.timestamp).toISOString().split("T")[0] + return ` - ${timestamp}: Modified files:\n${files}` + }) + recentHistorySection = ` +${historyItems.join("\n")} +` + } else { + recentHistorySection = ` + No recent changes found for this intent. +` + } + + return ` +${intent.id} +${intent.name} +${intent.status} + +${scopeList} + + +${constraintsList} + + +${criteriaList} + +${recentHistorySection} +` + } + + override async handlePartial(task: Task, block: ToolUse<"select_active_intent">): Promise { + // No partial handling needed for intent selection + } +} + +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba69361..6ae8eecfc3 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -265,6 +265,7 @@ export type ToolGroupConfig = { } export const TOOL_DISPLAY_NAMES: Record = { + select_active_intent: "select active intent", execute_command: "run commands", read_file: "read files", read_command_output: "read command output", @@ -314,6 +315,7 @@ export const TOOL_GROUPS: Record = { // Tools that are always available to all modes. export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ + "select_active_intent", "ask_followup_question", "attempt_completion", "switch_mode",