diff --git a/ARCHITECTURE_NOTES.md b/ARCHITECTURE_NOTES.md index 2bb10cd607..cf04f6434c 100644 --- a/ARCHITECTURE_NOTES.md +++ b/ARCHITECTURE_NOTES.md @@ -1,12 +1,3 @@ - - # Architecture Notes: Master Thinker Edition ## 1. Data Flow Map (Request-to-Execution) @@ -24,3 +15,38 @@ ## 3. Intent-Code Gap Analysis Standard Git tracks "What" changed but lacks the "Why." By using a sidecar orchestration layer, we map every Abstract Syntax Tree (AST) change to a specific Requirement ID. This prevents "Context Rot" where agents lose track of architectural constraints during long-running tasks. + +# Architectural Design Report + +## 1. The Intent-First Protocol (Two-Stage State Machine) + +The core of this implementation is a move away from "Vibe Coding" towards a governed, stateful interaction. I have architected a Two-Stage State Machine for every user request: + +**Stage 1: The Reasoning Intercept (The Handshake):** The agent is no longer permitted to generate code immediately. It must first analyze the request, identify a valid intent_id from the governance sidecar, and call the select_active_intent tool. ++1 + +**Stage 2: Contextualized Action:** Only after the "Handshake" is successful and the context is injected can the agent proceed to use destructive tools like write_to_file or execute_command. ++1 + +## 2. The Deterministic Hook (Gatekeeper Architecture) + +To ensure compliance, I implemented a Deterministic Hook System that acts as a strict middleware boundary: ++1 + +Pre-Hook Implementation: In WriteToFileTool.ts and ExecuteCommandTool.ts, I injected a gatekeeper check at the start of the handle method. + +Verification Logic: This hook verifies the presence of a global active intent flag. If the agent attempts a file modification without a validated "checkout," the hook blocks execution and returns a formal governance error: "You must cite a valid active Intent ID". + +Fail-Safe: This ensures that the architecture enforces the rules, rather than relying on the LLM's "best effort" to follow instructions. + +## 3. Context Engineering (Dynamic Injection vs. Context Rot) + +Traditional AI IDEs suffer from "Context Rot" by dumping entire file trees into the prompt. This implementation solves this via Dynamic Context Injection: ++1 + +**Sidecar Pattern:** All architectural constraints and business intents are stored in .orchestration/active_intents.yaml. ++1 + +**On-Demand Context:** When select_active_intent is called, the system reads the YAML and constructs a targeted XML block. + +**Traceability:** This ensures the agent only operates within its "owned_scope" and respects the "acceptance_criteria" defined in the sidecar, maintaining a high signal-to-noise ratio in the context window. diff --git a/intent_map.md b/intent_map.md new file mode 100644 index 0000000000..e3a179cd21 --- /dev/null +++ b/intent_map.md @@ -0,0 +1,8 @@ +# Intent-Code Mapping + +| Intent ID | Target Files / Modules | Status | +| --------- | ------------------------------------------------- | ----------- | +| INT-001 | src/core/tools/WriteToFileTool.ts | IN PROGRESS | +| INT-001 | src/core/assistant-msg/presentAssistantMessage.ts | COMPLETED | + +[cite_start]**Notes:** This map links the high-level business logic in `.orchestration/active_intents.yaml` to specific AST changes in the source code. diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..9a1ed019ca 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -39,6 +39,7 @@ export const toolNames = [ "ask_followup_question", "attempt_completion", "switch_mode", + "select_active_intent", "new_task", "codebase_search", "update_todo_list", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0ea1383f1..88b50bc7f5 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -546,6 +546,14 @@ export class NativeToolCallParser { } break + case "select_active_intent": + if (partialArgs.intent_id !== undefined) { + nativeArgs = { + intent_id: partialArgs.intent_id, + } + } + break + case "update_todo_list": if (partialArgs.todos !== undefined) { nativeArgs = { @@ -881,6 +889,14 @@ export class NativeToolCallParser { } break + case "select_active_intent": + if (args.intent_id !== undefined) { + nativeArgs = { + intent_id: args.intent_id, + } as NativeArgsFor + } + break + case "update_todo_list": if (args.todos !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..84ece4bd99 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -28,6 +28,7 @@ import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" import { switchModeTool } from "../tools/SwitchModeTool" +import { SelectActiveIntentTool } from "../tools/SelectActiveIntentTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" import { updateTodoListTool } from "../tools/UpdateTodoListTool" @@ -41,6 +42,8 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +const selectActiveIntentTool = new SelectActiveIntentTool() + /** * Processes and presents assistant message content to the user interface. * @@ -365,6 +368,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name}]` case "switch_mode": return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` + case "select_active_intent": + return `[${block.name} for '${block.params.intent_id}']` case "codebase_search": return `[${block.name} for '${block.params.query}']` case "read_command_output": @@ -803,6 +808,16 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "select_active_intent": { + try { + const intentId = block.nativeArgs?.intent_id ?? block.params.intent_id ?? "" + const result = await selectActiveIntentTool.handle({ intent_id: intentId }, cline.cwd) + pushToolResult(result) + } catch (error) { + await handleError("loading active intent context", error as Error) + } + break + } case "new_task": await checkpointSaveAndMark(cline) await newTaskTool.handle(cline, block as ToolUse<"new_task">, { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a..79fe6f137c 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -25,6 +25,9 @@ import { getSkillsSection, } from "./sections" +const INTENT_DRIVEN_ARCHITECT_RULE = + "You are an Intent-Driven Architect. You CANNOT write code immediately. Your first action MUST be to analyze the user request and call select_active_intent to load the necessary context and constraints." + // Helper function to get prompt component, filtering out empty objects export function getPromptComponent( customModePrompts: CustomModePrompts | undefined, @@ -82,7 +85,9 @@ async function generatePrompt( // Tools catalog is not included in the system prompt. const toolsCatalog = "" - const basePrompt = `${roleDefinition} + const basePrompt = `${INTENT_DRIVEN_ARCHITECT_RULE} + +${roleDefinition} ${markdownFormattingSection()} diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..6d58703fec 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -13,6 +13,7 @@ import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" +import selectActiveIntent from "./select_active_intent" import skill from "./skill" import searchReplace from "./search_replace" import edit_file from "./edit_file" @@ -60,6 +61,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, + selectActiveIntent, skill, searchReplace, edit_file, 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..2affca1fbd --- /dev/null +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -0,0 +1,25 @@ +import type OpenAI from "openai" + +const SELECT_ACTIVE_INTENT_DESCRIPTION = `Load the active intent context from .orchestration/active_intents.yaml by intent ID. Use this before planning or coding so constraints and scope are explicitly available in the next turn.` + +const INTENT_ID_PARAMETER_DESCRIPTION = `Intent ID to load from .orchestration/active_intents.yaml (for example: intent-1)` + +export default { + type: "function", + function: { + name: "select_active_intent", + description: SELECT_ACTIVE_INTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: INTENT_ID_PARAMETER_DESCRIPTION, + }, + }, + required: ["intent_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/SelectActiveIntentTool.ts b/src/core/tools/SelectActiveIntentTool.ts new file mode 100644 index 0000000000..9d8be89729 --- /dev/null +++ b/src/core/tools/SelectActiveIntentTool.ts @@ -0,0 +1,37 @@ +import * as fs from "fs" +import * as path from "path" +import * as yaml from "yaml" + +export class SelectActiveIntentTool { + async handle(params: { intent_id: string }, workspaceRoot: string): Promise { + if (!params.intent_id?.trim()) { + return "ERROR: Missing required parameter 'intent_id'." + } + try { + const content = await fs.promises.readFile( + path.join(workspaceRoot, ".orchestration", "active_intents.yaml"), + "utf-8", + ) + const data = yaml.parse(content) as any + const intents = Array.isArray(data) + ? data + : Array.isArray(data?.active_intents) + ? data.active_intents + : Array.isArray(data?.intents) + ? data.intents + : Object.entries(data ?? {}).map(([intent_id, entry]) => ({ intent_id, ...(entry as object) })) + const match = intents.find((intent: any) => (intent?.intent_id ?? intent?.id) === params.intent_id) + if (!match) { + return `ERROR: Intent '${params.intent_id}' not found in .orchestration/active_intents.yaml.` + } + return `${JSON.stringify(match, null, 2)}` + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return "ERROR: Governance sidecar not found at .orchestration/active_intents.yaml. Please initialize Phase 0 first." + } + return `ERROR: Failed to read or parse .orchestration/active_intents.yaml: ${ + error instanceof Error ? error.message : String(error) + }` + } + } +} diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c8455ef3d9..7f76cab32b 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -47,6 +47,17 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + const hasSelectedActiveIntent = (task.toolUsage.select_active_intent?.attempts ?? 0) > 0 + if (!hasSelectedActiveIntent) { + const governanceError = "GOVERNANCE ERROR: You must cite a valid active Intent ID before modifying files." + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file", governanceError) + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError(governanceError)) + await task.diffViewProvider.reset() + return + } + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { diff --git a/src/core/tools/__tests__/selectActiveIntentTool.spec.ts b/src/core/tools/__tests__/selectActiveIntentTool.spec.ts new file mode 100644 index 0000000000..abd0a1b12a --- /dev/null +++ b/src/core/tools/__tests__/selectActiveIntentTool.spec.ts @@ -0,0 +1,62 @@ +import * as fs from "fs" + +import { SelectActiveIntentTool } from "../SelectActiveIntentTool" + +describe("SelectActiveIntentTool", () => { + const mockedReadFile = vi.spyOn(fs.promises, "readFile") + const tool = new SelectActiveIntentTool() + const workspaceRoot = "/workspace" + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns intent_context XML for matching intent in intents array", async () => { + mockedReadFile.mockResolvedValue( + `intents: + - intent_id: intent-1 + constraints: + must_not: + - direct_db_writes + scope: + files: + - src/** +` as never, + ) + + const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot) + + expect(result).toContain("") + expect(result).toContain('"intent_id": "intent-1"') + expect(result).toContain('"constraints"') + expect(result).toContain("direct_db_writes") + expect(result).toContain('"scope"') + expect(result).toContain("src/**") + expect(result).toContain("") + }) + + it("returns not found error when intent_id does not exist", async () => { + mockedReadFile.mockResolvedValue( + `active_intents: + - intent_id: intent-2 + constraints: {} + scope: {} +` as never, + ) + + const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot) + + expect(result).toBe("ERROR: Intent 'intent-1' not found in .orchestration/active_intents.yaml.") + }) + + it("returns initialization error when sidecar file is missing", async () => { + const missingFileError = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + mockedReadFile.mockRejectedValue(missingFileError) + + const result = await tool.handle({ intent_id: "intent-1" }, workspaceRoot) + + expect(result).toBe( + "ERROR: Governance sidecar not found at .orchestration/active_intents.yaml. Please initialize Phase 0 first.", + ) + }) +}) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 6c63387ee1..cbf99341ba 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -123,6 +123,10 @@ describe("writeToFileTool", () => { mockCline.cwd = "/" mockCline.consecutiveMistakeCount = 0 mockCline.didEditFile = false + mockCline.didToolFailInCurrentTurn = false + mockCline.toolUsage = { + select_active_intent: { attempts: 1, failures: 0 }, + } mockCline.diffStrategy = undefined mockCline.providerRef = { deref: vi.fn().mockReturnValue({ @@ -236,6 +240,22 @@ describe("writeToFileTool", () => { } describe("access control", () => { + it("blocks write when no active intent has been selected in this session", async () => { + mockCline.toolUsage = {} + + const result = await executeWriteFileTool({}) + + expect(result).toBe( + "Error: GOVERNANCE ERROR: You must cite a valid active Intent ID before modifying files.", + ) + expect(mockCline.recordToolError).toHaveBeenCalledWith( + "write_to_file", + "GOVERNANCE ERROR: You must cite a valid active Intent ID before modifying files.", + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + }) + it("validates and allows access when rooIgnoreController permits", async () => { await executeWriteFileTool({}, { accessAllowed: true }) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba69361..a6c65d5c08 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -58,6 +58,7 @@ export const toolParamNames = [ "todos", "prompt", "image", + "intent_id", // read_file parameters (native protocol) "operations", // search_and_replace parameter for multiple operations "patch", // apply_patch parameter @@ -112,6 +113,7 @@ export type NativeToolArgs = { skill: { skill: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } switch_mode: { mode_slug: string; reason: string } + select_active_intent: { intent_id: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } @@ -282,6 +284,7 @@ export const TOOL_DISPLAY_NAMES: Record = { ask_followup_question: "ask questions", attempt_completion: "complete tasks", switch_mode: "switch modes", + select_active_intent: "load intent context", new_task: "create new task", codebase_search: "codebase search", update_todo_list: "update todo list", @@ -307,7 +310,7 @@ export const TOOL_GROUPS: Record = { tools: ["use_mcp_tool", "access_mcp_resource"], }, modes: { - tools: ["switch_mode", "new_task"], + tools: ["switch_mode", "select_active_intent", "new_task"], alwaysAvailable: true, }, } @@ -317,6 +320,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "ask_followup_question", "attempt_completion", "switch_mode", + "select_active_intent", "new_task", "update_todo_list", "run_slash_command",