diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0ea1383f1..e49e67838b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -468,6 +468,12 @@ export class NativeToolCallParser { nativeArgs = { path: partialArgs.path, content: partialArgs.content, + intent_id: partialArgs.intent_id, + mutation_class: partialArgs.mutation_class as + | "AST_REFACTOR" + | "INTENT_EVOLUTION" + | "NEW_FILE" + | undefined, } } break @@ -905,6 +911,12 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, content: args.content, + intent_id: args.intent_id, + mutation_class: args.mutation_class as + | "AST_REFACTOR" + | "INTENT_EVOLUTION" + | "NEW_FILE" + | undefined, } as NativeArgsFor } break diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ced072d1f0..d1cc063a84 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -676,18 +676,48 @@ export async function presentAssistantMessage(cline: Task) { } } + // Hook Engine: Post-Hook for write_to_file appends to agent_trace.jsonl + const preHook = new PreHook({ + cwd: cline.cwd, + getActiveIntentId: () => cline.getActiveIntentId(), + setActiveIntentId: (id) => cline.setActiveIntentId(id), + requireIntentForDestructiveOnly: true, + }) + const hookMiddleware = new HookMiddleware({ + preHook, + getActiveIntentId: () => cline.getActiveIntentId(), + getCwd: () => cline.cwd, + getReqId: () => cline.taskId, + getSessionLogId: () => undefined, + getModelId: () => cline.api.getModel()?.id, + getVcsRevisionId: () => undefined, + }) + switch (block.name) { case "select_active_intent": // Handled entirely by pre-hook (injectResult pushed above) break - case "write_to_file": + case "write_to_file": { await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { askApproval, handleError, pushToolResult, + onWriteToFileSuccess: async (p) => { + await hookMiddleware.postToolUse( + "write_to_file", + { + path: p.path, + content: p.content, + intent_id: p.intent_id, + mutation_class: p.mutation_class, + }, + {}, + ) + }, }) break + } case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index b9e9b313a2..35c8d2c791 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -1,5 +1,7 @@ import type OpenAI from "openai" +const MUTATION_CLASS_ENUM = ["AST_REFACTOR", "INTENT_EVOLUTION", "NEW_FILE"] as const + const WRITE_TO_FILE_DESCRIPTION = `Request to write content to a file. This tool is primarily used for creating new files or for scenarios where a complete rewrite of an existing file is intentionally required. If the file exists, it will be overwritten. If it doesn't exist, it will be created. This tool will automatically create any directories needed to write the file. **Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. @@ -8,13 +10,22 @@ When using this tool, use it directly with the desired content. You do not need When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. +**Traceability:** You MUST provide intent_id (the active intent from select_active_intent) and mutation_class: +- AST_REFACTOR: Syntax/structural change, same intent (e.g. rename, format, extract function). +- INTENT_EVOLUTION: New feature or behavior change tied to the intent. +- NEW_FILE: Creating a file that did not exist before. + Example: Writing a configuration file -{ "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}" }` +{ "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}", "intent_id": "INT-001", "mutation_class": "INTENT_EVOLUTION" }` const PATH_PARAMETER_DESCRIPTION = `The path of the file to write to (relative to the current workspace directory)` const CONTENT_PARAMETER_DESCRIPTION = `The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content.` +const INTENT_ID_DESCRIPTION = `The ID of the active intent (from select_active_intent) that this write serves. Required for traceability.` + +const MUTATION_CLASS_DESCRIPTION = `Semantic classification: AST_REFACTOR (syntax change, same intent), INTENT_EVOLUTION (new feature/behavior), or NEW_FILE (creating a new file).` + export default { type: "function", function: { @@ -32,8 +43,17 @@ export default { type: "string", description: CONTENT_PARAMETER_DESCRIPTION, }, + intent_id: { + type: "string", + description: INTENT_ID_DESCRIPTION, + }, + mutation_class: { + type: "string", + enum: MUTATION_CLASS_ENUM, + description: MUTATION_CLASS_DESCRIPTION, + }, }, - required: ["path", "content"], + required: ["path", "content", "intent_id", "mutation_class"], additionalProperties: false, }, }, diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 19f137556d..916e0e595a 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -7,6 +7,7 @@ import type { ToolUse, HandleError, PushToolResult, AskApproval, NativeToolArgs export interface WriteToFileSuccessParams { path: string content: string + intent_id?: string mutation_class?: string } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 3fe0d4166c..d511f9d877 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -180,9 +180,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) const mutationClass = (params as Record).mutation_class as string | undefined + const intentId = (params as Record).intent_id as string | undefined await callbacks.onWriteToFileSuccess?.({ path: relPath, content: newContent, + intent_id: intentId, mutation_class: mutationClass, }) diff --git a/src/hooks/content-hash.ts b/src/hooks/content-hash.ts index 538666b1b3..59dbc35bac 100644 --- a/src/hooks/content-hash.ts +++ b/src/hooks/content-hash.ts @@ -3,14 +3,23 @@ import crypto from "crypto" const HASH_PREFIX = "sha256:" /** - * Compute a SHA-256 content hash for spatial independence. - * If lines move, the hash of the content block remains valid. + * Generate a SHA-256 hash of string content (spatial hashing utility). + * Returns a prefixed hex string (e.g. "sha256:abc123...") for traceability. + * Content hash remains valid even if line positions change. */ export function contentHash(content: string): string { const hash = crypto.createHash("sha256").update(content, "utf8").digest("hex") return `${HASH_PREFIX}${hash}` } +/** + * Alias for contentHash - generates SHA-256 hash of string content. + * Use for spatial hashing in agent trace and diff operations. + */ +export function sha256Hash(content: string): string { + return contentHash(content) +} + /** * Extract a logical code block (e.g. by line range) and return its hash. */ diff --git a/src/hooks/format.ts b/src/hooks/format.ts new file mode 100644 index 0000000000..1f8361b10a --- /dev/null +++ b/src/hooks/format.ts @@ -0,0 +1,31 @@ +/** + * Minimal tool error formatters for pre-hook. + * Avoids importing from core/prompts/responses to prevent vscode dependency in Node scripts. + */ +export const toolErrorFormat = { + toolError: (error?: string) => + JSON.stringify({ + status: "error", + message: "The tool execution failed", + error, + }), + + toolErrorScopeViolation: (intentId: string, filename: string) => + JSON.stringify({ + status: "error", + type: "scope_violation", + message: `Scope Violation: ${intentId} is not authorized to edit [${filename}]. Request scope expansion.`, + intent_id: intentId, + path: filename, + suggestion: "Request scope expansion in .orchestration/active_intents.yaml or choose another intent.", + }), + + toolErrorUserRejected: (toolName?: string) => + JSON.stringify({ + status: "error", + type: "user_rejected", + message: "The user rejected this operation.", + tool: toolName, + suggestion: "Do not retry the same operation; try a different approach or ask the user for permission.", + }), +} diff --git a/src/hooks/middleware.ts b/src/hooks/middleware.ts index 1b2f437ce4..7ff0819f6c 100644 --- a/src/hooks/middleware.ts +++ b/src/hooks/middleware.ts @@ -6,6 +6,8 @@ export interface HookMiddlewareOptions { preHook: PreHook getActiveIntentId: () => string | null getCwd: () => string + /** REQ-ID from Phase 1 - injected into agent_trace related array */ + getReqId?: () => string | undefined getSessionLogId?: () => string | undefined getModelId?: () => string | undefined getVcsRevisionId?: () => string | undefined @@ -39,7 +41,8 @@ export class HookMiddleware { const contentParam = params.content if (typeof pathParam !== "string" || typeof contentParam !== "string") return - const intentId = this.options.getActiveIntentId() + const intentId = + (typeof params.intent_id === "string" ? params.intent_id.trim() : null) || this.options.getActiveIntentId() const mutationClass = (params.mutation_class as "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE") ?? "UNKNOWN" await appendAgentTrace(this.options.getCwd(), { @@ -47,6 +50,7 @@ export class HookMiddleware { content: contentParam, intentId, mutationClass, + reqId: this.options.getReqId?.(), sessionLogId: this.options.getSessionLogId?.(), modelIdentifier: this.options.getModelId?.(), vcsRevisionId: this.options.getVcsRevisionId?.(), diff --git a/src/hooks/post-hook.ts b/src/hooks/post-hook.ts index 16c8e13a6e..0857501098 100644 --- a/src/hooks/post-hook.ts +++ b/src/hooks/post-hook.ts @@ -13,6 +13,8 @@ export interface PostHookWriteParams { content: string intentId: string | null mutationClass?: MutationClass + /** REQ-ID from Phase 1 - injected into related array for traceability */ + reqId?: string sessionLogId?: string modelIdentifier?: string vcsRevisionId?: string @@ -28,6 +30,7 @@ export async function appendAgentTrace(cwd: string, params: PostHookWriteParams) content, intentId, mutationClass = "UNKNOWN", + reqId, sessionLogId, modelIdentifier = "unknown", vcsRevisionId, @@ -39,11 +42,15 @@ export async function appendAgentTrace(cwd: string, params: PostHookWriteParams) const lines = content.split("\n") const fullRangeHash = contentHash(content) + const related: Array<{ type: string; value: string }> = [] + if (intentId) related.push({ type: "specification", value: intentId }) + if (reqId) related.push({ type: "request", value: reqId }) + const conversation: AgentTraceConversation = { url: sessionLogId, contributor: { entity_type: "AI", model_identifier: modelIdentifier }, ranges: [{ start_line: 1, end_line: lines.length, content_hash: fullRangeHash }], - related: intentId ? [{ type: "specification", value: intentId }] : [], + related, } const fileEntry: AgentTraceFileEntry = { diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index 8b85180786..4b4f297739 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -3,7 +3,10 @@ import path from "path" import type { HookResult } from "./types" import { DESTRUCTIVE_TOOLS } from "./types" import { loadIntentContext, buildConsolidatedIntentContextXml } from "./context-loader" +import { loadIntentIgnore, isPathIgnored, isIntentExcluded } from "./intent-ignore" +import type { IntentIgnoreResult } from "./intent-ignore" import { pathInScope } from "./scope" +import { toolErrorFormat } from "./format" /** Block paths that escape workspace (.. or absolute outside cwd). */ function isPathTraversal(relPath: string, cwd: string): boolean { @@ -76,7 +79,7 @@ export class PreHook { if (isIntentExcluded(activeId, ignore.excludedIntentIds)) { return { blocked: true, - error: formatResponse.toolError( + error: toolErrorFormat.toolError( `Intent ${activeId} is listed in .intentignore and cannot be modified. Choose another intent or ask the user to update .intentignore.`, ), } @@ -107,7 +110,7 @@ export class PreHook { if (isPathIgnored(relPath, ignore.pathPatterns)) { return { blocked: true, - error: formatResponse.toolError( + error: toolErrorFormat.toolError( `Path "${relPath}" is excluded by .intentignore. You are not authorized to edit it.`, ), } @@ -116,7 +119,7 @@ export class PreHook { if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { return { blocked: true, - error: formatResponse.toolErrorScopeViolation(activeId, relPath), + error: toolErrorFormat.toolErrorScopeViolation(activeId, relPath), } } } @@ -128,7 +131,7 @@ export class PreHook { if (!approved) { return { blocked: true, - error: formatResponse.toolErrorUserRejected(toolName), + error: toolErrorFormat.toolErrorUserRejected(toolName), } } } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba69361..f09bed4688 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -80,6 +80,9 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + // write_to_file traceability (AI-Native Git Layer) + "intent_id", + "mutation_class", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -114,7 +117,12 @@ export type NativeToolArgs = { switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } - write_to_file: { path: string; content: string } + write_to_file: { + path: string + content: string + intent_id?: string + mutation_class?: "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE" + } // Add more tools as they are migrated to native protocol }