From 989e975c96a7801b929ed8366f7c62014a4b0980 Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Tue, 6 Jan 2026 20:06:20 -0800 Subject: [PATCH 1/5] feat(planning): introduce create_draft tool for ephemeral document creation - Added tool to facilitate the creation of temporary, in-memory planning documents. - Implemented functionality to handle draft documents, including reading and writing operations. - Integrated draft management into the existing file system provider, allowing drafts to be opened as editor tabs. - Updated relevant documentation and tests to reflect the new tool and its usage. This enhancement improves the user experience by enabling structured thinking and planning without the need for persistent storage. --- .changeset/planning-doc-tool.md | 5 + packages/types/src/tool.ts | 1 + .../presentAssistantMessage.ts | 12 + .../architect-mode-prompt.snap | 51 ++ .../ask-mode-prompt.snap | 46 ++ .../mcp-server-creation-disabled.snap | 46 ++ .../mcp-server-creation-enabled.snap | 46 ++ .../partial-reads-enabled.snap | 46 ++ src/core/prompts/tools/create-draft.ts | 50 ++ .../prompts/tools/filter-tools-for-mode.ts | 10 + src/core/prompts/tools/index.ts | 6 +- .../tools/native-tools/create_draft.ts | 47 ++ src/core/prompts/tools/native-tools/index.ts | 2 + src/core/tools/ApplyDiffTool.ts | 107 +++- src/core/tools/CreateDraftTool.ts | 98 ++++ src/core/tools/ReadFileTool.ts | 34 ++ src/core/tools/SearchAndReplaceTool.ts | 98 ++++ src/core/tools/WriteToFileTool.ts | 22 +- .../tools/__tests__/createDraftTool.spec.ts | 219 ++++++++ .../tools/helpers/draftDocumentHelpers.ts | 142 +++++ src/core/tools/simpleReadFileTool.ts | 25 +- src/extension.ts | 3 + .../planning/DraftFileSystemProvider.ts | 415 +++++++++++++++ .../__tests__/draftFileSystemProvider.spec.ts | 488 ++++++++++++++++++ .../planning/__tests__/draftPaths.spec.ts | 143 +++++ src/services/planning/draftPaths.ts | 100 ++++ src/services/planning/index.ts | 10 + src/shared/tools.ts | 11 +- 28 files changed, 2266 insertions(+), 17 deletions(-) create mode 100644 .changeset/planning-doc-tool.md create mode 100644 src/core/prompts/tools/create-draft.ts create mode 100644 src/core/prompts/tools/native-tools/create_draft.ts create mode 100644 src/core/tools/CreateDraftTool.ts create mode 100644 src/core/tools/__tests__/createDraftTool.spec.ts create mode 100644 src/core/tools/helpers/draftDocumentHelpers.ts create mode 100644 src/services/planning/DraftFileSystemProvider.ts create mode 100644 src/services/planning/__tests__/draftFileSystemProvider.spec.ts create mode 100644 src/services/planning/__tests__/draftPaths.spec.ts create mode 100644 src/services/planning/draftPaths.ts create mode 100644 src/services/planning/index.ts diff --git a/.changeset/planning-doc-tool.md b/.changeset/planning-doc-tool.md new file mode 100644 index 00000000000..c892212ac23 --- /dev/null +++ b/.changeset/planning-doc-tool.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Add `create_draft` tool for creating ephemeral planning documents diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index c28e78ce5a0..cfb3721d442 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -39,6 +39,7 @@ export const toolNames = [ "report_bug", "condense", "delete_file", + "create_draft", // kilocode_change end "update_todo_list", "run_slash_command", diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 0ea50ba0bc8..a96163dd88f 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -31,6 +31,7 @@ import { switchModeTool } from "../tools/SwitchModeTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" +import { createDraftTool } from "../tools/CreateDraftTool" // kilocode_change import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { generateImageTool } from "../tools/GenerateImageTool" @@ -466,6 +467,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name}]` case "condense": return `[${block.name}]` + case "create_draft": + return `[${block.name} for '${block.params.title}']` // kilocode_change end case "run_slash_command": return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` @@ -1099,6 +1102,15 @@ export async function presentAssistantMessage(cline: Task) { case "condense": await condenseTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break + case "create_draft": + await createDraftTool.handle(cline, block as ToolUse<"create_draft">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break // kilocode_change end case "run_slash_command": diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index 093e0d50222..b1df753cce7 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -229,6 +229,57 @@ Delete a directory (requires approval with statistics): ``` +## create_draft +Description: Create a temporary planning document for structuring your analysis, architectural decisions, implementation plans, or other working documents. These documents are ideal for drafting content that you want the user to review before committing to the codebase. + +Use create_draft when: +- Creating planning documents, implementation plans, or architectural diagrams +- Drafting content for user review and feedback +- Organizing your analysis and reasoning process +- The user wants to review and refine plans before implementation + +The created document will: +- Appear as a normal editor tab that can be edited by both you and the user +- Be accessible via read_file and write_to_file tools +- Be saved to disk (outside the repository) and persist across sessions + +When to use create_draft vs write_to_file: +- Use create_draft for planning documents, drafts, and temporary working documents +- Use write_to_file when the user wants content saved directly to the workspace (e.g., explicit file creation requests, writing docs that should be committed to the repo) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +...more + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## ask_followup_question Description: Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index 3cb0ec02e2b..2ff7f0ece06 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -231,6 +231,52 @@ Example: +## create_draft +Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## update_todo_list **Description:** diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap index f7df983c3b9..122fcb3d6e3 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap @@ -228,6 +228,52 @@ Delete a directory (requires approval with statistics): ``` +## create_draft +Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap index cc7113024ac..f7736ca7b40 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap @@ -229,6 +229,52 @@ Delete a directory (requires approval with statistics): ``` +## create_draft +Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap index 1d9f2436c0e..6d1a9bbb698 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap @@ -234,6 +234,52 @@ Delete a directory (requires approval with statistics): ``` +## create_draft +Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/tools/create-draft.ts b/src/core/prompts/tools/create-draft.ts new file mode 100644 index 00000000000..ee219138ae8 --- /dev/null +++ b/src/core/prompts/tools/create-draft.ts @@ -0,0 +1,50 @@ +// kilocode_change start: Add create_draft tool description function +import { ToolArgs } from "./types" + +export function getCreateDraftDescription(args: ToolArgs): string { + return `## create_draft +Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) +- content: (required) The initial content of the draft document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + +` +} diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5d16e7f0be7..bf0584ea304 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -10,6 +10,7 @@ import type { McpHub } from "../../../services/mcp/McpHub" import { ClineProviderState } from "../../webview/ClineProvider" import { isFastApplyAvailable } from "../../tools/kilocode/editFileTool" import { ManagedIndexer } from "../../../services/code-index/managed/ManagedIndexer" +import { getKiloCodeWrapperProperties } from "../../../core/kilocode/wrapper" // kilocode_change end /** @@ -341,6 +342,15 @@ export function filterNativeToolsForMode( allowedToolNames.delete("access_mcp_resource") } + // kilocode_change start - create_draft tool exclusion + // Conditionally exclude create_draft if running in CLI or JetBrains mode + // (drafts require VS Code editor UI which CLI and JetBrains don't have) + const { kiloCodeWrapperCode, kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() + if (kiloCodeWrapperJetbrains || kiloCodeWrapperCode === "cli") { + allowedToolNames.delete("create_draft") + } + // kilocode_change end - create_draft tool exclusion + // Filter native tools based on allowed tool names and apply alias renames const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 5dd516a7316..ff3ba273d1e 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -27,12 +27,13 @@ import { getGenerateImageDescription } from "./generate-image" import { getDeleteFileDescription } from "./delete-file" // kilocode_change import { CodeIndexManager } from "../../../services/code-index/manager" -// kilocode_change start: Morph fast apply +// kilocode_change start: Morph fast apply + create_draft import import { isFastApplyAvailable } from "../../tools/kilocode/editFileTool" import { getEditFileDescription } from "./edit-file" import { type ClineProviderState } from "../../webview/ClineProvider" import { ManagedIndexer } from "../../../services/code-index/managed/ManagedIndexer" -// kilocode_change end +import { getCreateDraftDescription } from "./create-draft" +// kilocode_change end: Morph fast apply + create_draft import // Map of tool names to their description functions const toolDescriptionMap: Record string | undefined> = { @@ -59,6 +60,7 @@ const toolDescriptionMap: Record string | undefined> new_task: (args) => getNewTaskDescription(args), edit_file: () => getEditFileDescription(), // kilocode_change: Morph fast apply delete_file: (args) => getDeleteFileDescription(args), // kilocode_change + create_draft: (args) => getCreateDraftDescription(args), // kilocode_change apply_diff: (args) => args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", update_todo_list: (args) => getUpdateTodoListDescription(args), diff --git a/src/core/prompts/tools/native-tools/create_draft.ts b/src/core/prompts/tools/native-tools/create_draft.ts new file mode 100644 index 00000000000..9a07ada660b --- /dev/null +++ b/src/core/prompts/tools/native-tools/create_draft.ts @@ -0,0 +1,47 @@ +// kilocode_change - new file: Native tool definition for create_draft +import type OpenAI from "openai" + +const CREATE_DRAFT_DESCRIPTION = `Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the draft document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the draft document + +Example: Creating a planning document +{ "title": "implementation-plan", "content": "# Implementation Plan\n\n## Step 1\n- Task A\n- Task B\n\n## Step 2\n- Task C" } + +Example: Creating a quick note +{ "title": "quick-notes", "content": "Remember to:\n1. Check API documentation\n2. Test edge cases\n3. Update tests" }` + +const TITLE_PARAMETER_DESCRIPTION = `The title/name of the draft document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present)` + +const CONTENT_PARAMETER_DESCRIPTION = `The initial content of the draft document` + +export default { + type: "function", + function: { + name: "create_draft", + description: CREATE_DRAFT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + title: { + type: "string", + description: TITLE_PARAMETER_DESCRIPTION, + }, + content: { + type: "string", + description: CONTENT_PARAMETER_DESCRIPTION, + }, + }, + required: ["title", "content"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 8c1bafb274f..f04b8c54fb3 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -6,6 +6,7 @@ import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" import codebaseSearch from "./codebase_search" +import createDraft from "./create_draft" // kilocode_change import executeCommand from "./execute_command" import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" @@ -41,6 +42,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat // condenseTool, // newRuleTool, // reportBugTool, + createDraft, // kilocode_change // kilocode_change end accessMcpResource, apply_diff, diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 0c33708c472..4974742dda2 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -1,5 +1,6 @@ import path from "path" import fs from "fs/promises" +import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" @@ -16,6 +17,7 @@ import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change +import { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } from "../../services/planning" // kilocode_change interface ApplyDiffParams { path: string @@ -55,6 +57,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } + // kilocode_change start: Handle draft documents + const isDraft = isDraftPath(relPath) + const canonicalPath = isDraft ? normalizeDraftPath(relPath) : relPath + const filename = isDraft ? draftPathToFilename(relPath) : undefined + + // kilocode_change end + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { @@ -63,20 +72,50 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - const absolutePath = path.resolve(task.cwd, relPath) - const fileExists = await fileExistsAtPath(absolutePath) + // kilocode_change start: Handle draft documents + let originalContent: string + let absolutePath: string + let fileExists = false // kilocode_change + + if (isDraft) { + // For draft documents, read using the draft file system + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + console.log("📝 [ApplyDiffTool] reading draft document:", uri.toString()) + try { + const contentBytes = await vscode.workspace.fs.readFile(uri) + originalContent = new TextDecoder().decode(contentBytes) + console.log("📝 [ApplyDiffTool] draft read successful, size:", originalContent.length) + fileExists = true // kilocode_change: draft exists since we just read it + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + console.error("📝 [ApplyDiffTool] ERROR reading draft:", errorMsg) + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + const formattedError = `Draft document does not exist at path: ${canonicalPath}\n\n\nThe draft document could not be found. Please verify the draft exists and try again.\n` + await task.say("error", formattedError) + task.didToolFailInCurrentTurn = true + pushToolResult(formattedError) + return + } + absolutePath = canonicalPath + } else { + // For regular files, use the existing logic + absolutePath = path.resolve(task.cwd, relPath) + fileExists = await fileExistsAtPath(absolutePath) + + if (!fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` + await task.say("error", formattedError) + task.didToolFailInCurrentTurn = true + pushToolResult(formattedError) + return + } - if (!fileExists) { - task.consecutiveMistakeCount++ - task.recordToolError("apply_diff") - const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await task.say("error", formattedError) - task.didToolFailInCurrentTurn = true - pushToolResult(formattedError) - return + originalContent = await fs.readFile(absolutePath, "utf-8") } - - const originalContent: string = await fs.readFile(absolutePath, "utf-8") + // kilocode_change end // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( @@ -152,6 +191,50 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diff: diffContent, } + // kilocode_change start: Handle draft documents separately + if (isDraft) { + // For draft documents, apply the diff and write directly using vscode.workspace.fs + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + console.log("📝 [ApplyDiffTool] applying diff to draft document:", uri.toString()) + + // Apply the diff to the original content + const diffResult = (await task.diffStrategy?.applyDiff( + originalContent, + diffContent, + parseInt(params.diff.match(/:start_line:(\d+)/)?.[1] ?? ""), + )) ?? { + success: false, + error: "No diff strategy available", + } + + if (!diffResult.success) { + task.consecutiveMistakeCount++ + let formattedError = `Unable to apply diff to draft document: ${canonicalPath}\n\n\n${diffResult.error || "Unknown error"}\n` + await task.say("error", formattedError) + task.recordToolError("apply_diff", formattedError) + pushToolResult(formattedError) + return + } + + task.consecutiveMistakeCount = 0 + + // Write the updated content back to the draft + const contentBytes = new TextEncoder().encode(diffResult.content) + await vscode.workspace.fs.writeFile(uri, contentBytes) + console.log("📝 [ApplyDiffTool] draft updated successfully") + + // Track file edit operation + await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) + task.didEditFile = true + + // Generate a simple message for the tool result + const message = `Applied diff to draft document: ${canonicalPath}` + pushToolResult(message) + task.processQueuedMessages() + return + } + // kilocode_change end + if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view const completeMessage = JSON.stringify({ diff --git a/src/core/tools/CreateDraftTool.ts b/src/core/tools/CreateDraftTool.ts new file mode 100644 index 00000000000..69216e239a5 --- /dev/null +++ b/src/core/tools/CreateDraftTool.ts @@ -0,0 +1,98 @@ +// kilocode_change - new file +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { getDraftFileSystem } from "../../services/planning" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface CreateDraftParams { + title: string + content: string +} + +export class CreateDraftTool extends BaseTool<"create_draft"> { + readonly name = "create_draft" as const + + parseLegacy(params: Partial>): CreateDraftParams { + return { + title: params.title || "", + content: params.content || "", + } + } + + async execute(params: CreateDraftParams, task: Task, callbacks: ToolCallbacks): Promise { + const { title, content } = params + const { handleError, pushToolResult } = callbacks + + console.log("📝 [CreateDraftTool] execute title=", title, "contentLength=", content.length) + + // Validate required parameters + if (!title) { + task.consecutiveMistakeCount++ + task.recordToolError("create_draft") + pushToolResult(await task.sayAndCreateMissingParamError("create_draft", "title")) + return + } + + if (content === undefined || content === null) { + task.consecutiveMistakeCount++ + task.recordToolError("create_draft") + pushToolResult(await task.sayAndCreateMissingParamError("create_draft", "content")) + return + } + + // Validate title length + if (title.length > 255) { + task.consecutiveMistakeCount++ + task.recordToolError("create_draft") + pushToolResult(formatResponse.toolError("Title must be 255 characters or less")) + return + } + + // Validate content is not too large (prevent memory issues) + if (content.length > 1000000) { + task.consecutiveMistakeCount++ + task.recordToolError("create_draft") + pushToolResult(formatResponse.toolError("Content must be 1MB or less")) + return + } + + task.consecutiveMistakeCount = 0 + + try { + const fs = getDraftFileSystem() + console.log("📝 [CreateDraftTool] calling fs.createAndOpen") + const draftPath = await fs.createAndOpen(title, content) + console.log("📝 [CreateDraftTool] fs.createAndOpen returned draftPath=", draftPath) + + // Return success message with instructions + const message = `Created draft document "${title}". The document has been opened in an editor tab.\n\nYou can now:\n- Read it using: read_file with path "${draftPath}"\n- Update it using: write_to_file with path "${draftPath}"\n\nThe document will be discarded when the editor session ends.` + + pushToolResult(formatResponse.toolResult(message)) + task.recordToolUsage("create_draft") + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + console.log("📝 [CreateDraftTool] error=", errorMessage) + pushToolResult(formatResponse.toolError(`Failed to create draft document: ${errorMessage}`)) + await handleError("creating draft document", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"create_draft">): Promise { + // Show "Creating draft..." message during streaming + const title = this.removeClosingTag("title", block.params.title, block.partial) + const content = this.removeClosingTag("content", block.params.content, block.partial) + + if (title) { + const partialMessage = JSON.stringify({ + tool: "createDraft", + title: title, + content: content, + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } + } +} + +export const createDraftTool = new CreateDraftTool() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d21c8cd247a..8b2dc16d84c 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -25,6 +25,7 @@ import { processImageFile, ImageMemoryTracker, } from "./helpers/imageHelpers" +import { isDraftPath, readDraftDocument } from "./helpers/draftDocumentHelpers" // kilocode_change import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget" import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -176,6 +177,14 @@ export class ReadFileTool extends BaseTool<"read_file"> { } if (fileResult.status === "pending") { + // kilocode_change start: Skip approval for draft documents + // Skip approval for draft documents (auto-approved) + if (isDraftPath(relPath)) { + updateFileResult(relPath, { status: "approved" }) + continue + } + // kilocode_change end + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { await task.say("rooignore_error", relPath) @@ -334,6 +343,30 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (fileResult.status !== "approved") continue const relPath = fileResult.path + + // kilocode_change start: Handle draft document reading + if (isDraftPath(relPath)) { + const result = await readDraftDocument(relPath, task) + if (result.status === "error") { + updateFileResult(relPath, { + status: "error", + error: result.error, + xmlContent: result.xmlContent, + nativeContent: result.nativeContent, + }) + if (result.error) { + await handleError(`reading draft document ${relPath}`, new Error(result.error)) + } + } else { + updateFileResult(relPath, { + xmlContent: result.xmlContent, + nativeContent: result.nativeContent, + }) + } + continue + } + // kilocode_change end + const fullPath = path.resolve(task.cwd, relPath) try { @@ -726,6 +759,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { return `[${blockName} with missing path/args/files]` } + // kilocode_change end override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { const argsXmlTag = block.params.args diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 675bea589ea..4740797ac64 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import path from "path" +import * as vscode from "vscode" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -14,6 +15,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { normalizeLineEndings_kilocode } from "./kilocode/normalizeLineEndings" +import { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } from "./helpers/draftDocumentHelpers" // kilocode_change interface SearchReplaceOperation { search: string @@ -86,6 +88,102 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } } + // kilocode_change start: Handle draft documents + if (isDraftPath(relPath)) { + const canonicalPath = normalizeDraftPath(relPath) + const filename = draftPathToFilename(relPath) + + // Read draft document + let fileContent: string + try { + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + const contentBytes = await vscode.workspace.fs.readFile(uri) + fileContent = new TextDecoder().decode(contentBytes) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + const errorMsg = error instanceof Error ? error.message : "Unknown error" + const errorMessage = `Failed to read draft document '${relPath}': ${errorMsg}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const useCrLf_kilocode = fileContent.includes("\r\n") + + // Apply all operations sequentially + let newContent = fileContent + const errors: string[] = [] + + for (let i = 0; i < operations.length; i++) { + const { search, replace } = operations[i] + const searchPattern = new RegExp( + escapeRegExp(normalizeLineEndings_kilocode(search, useCrLf_kilocode)), + "g", + ) + + const matchCount = newContent.match(searchPattern)?.length ?? 0 + if (matchCount === 0) { + errors.push(`Operation ${i + 1}: No match found for search text.`) + continue + } + + if (matchCount > 1) { + errors.push( + `Operation ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`, + ) + continue + } + + // Apply the replacement + newContent = newContent.replace( + searchPattern, + normalizeLineEndings_kilocode(replace, useCrLf_kilocode), + ) + } + + // If all operations failed, return error + if (errors.length === operations.length) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace", "no_match") + pushToolResult(formatResponse.toolError(`All operations failed:\n${errors.join("\n")}`)) + return + } + + // Check if any changes were made + if (newContent === fileContent) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + + // Write draft document + try { + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + const contentBytes = new TextEncoder().encode(newContent) + await vscode.workspace.fs.writeFile(uri, contentBytes) + + await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) + task.didEditFile = true + + // Add error info if some operations failed + let resultMessage = "" + if (errors.length > 0) { + resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` + } + resultMessage += `Updated draft document "${canonicalPath}"` + + pushToolResult(formatResponse.toolResult(resultMessage)) + task.recordToolUsage("search_and_replace") + return + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + await handleError("writing draft document", new Error(errorMsg)) + pushToolResult(formatResponse.toolError(`Failed to write draft document: ${errorMsg}`)) + return + } + } + // kilocode_change end + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c7a06fc1e62..d906f632f7b 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -1,6 +1,5 @@ import path from "path" import delay from "delay" -import * as vscode from "vscode" import fs from "fs/promises" import { Task } from "../task/Task" @@ -17,6 +16,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { isDraftPath, writeDraftDocument } from "./helpers/draftDocumentHelpers" // kilocode_change import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface WriteToFileParams { @@ -55,6 +55,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + // Check if this is a draft document + if (isDraftPath(relPath)) { + const result = await writeDraftDocument(relPath, newContent, task) + if ("error" in result) { + pushToolResult(formatResponse.toolError(result.error)) + await handleError("writing draft document", new Error(result.error)) + return + } + + task.didEditFile = true + pushToolResult(formatResponse.toolResult(`Updated draft document "${result.canonicalPath}"`)) + return + } + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { @@ -239,6 +253,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + // Skip partial handling for draft documents - they don't use the diff view provider + // and we don't want to create filesystem directories for draft:// paths + if (isDraftPath(relPath)) { + return + } + const provider = task.providerRef.deref() const state = await provider?.getState() const isPreventFocusDisruptionEnabled = experiments.isEnabled( diff --git a/src/core/tools/__tests__/createDraftTool.spec.ts b/src/core/tools/__tests__/createDraftTool.spec.ts new file mode 100644 index 00000000000..ab2f5cf4d2d --- /dev/null +++ b/src/core/tools/__tests__/createDraftTool.spec.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { createDraftTool } from "../CreateDraftTool" +import { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" +import { getDraftFileSystem } from "../../../services/planning" + +// Mock the planning service +vi.mock("../../../services/planning", () => ({ + getDraftFileSystem: vi.fn(() => ({ + createAndOpen: vi.fn(), + })), +})) + +// Mock vscode for integration tests - using vi.doMock to avoid hoisting issues +vi.doMock("vscode", () => ({ + Uri: { + parse: vi.fn((str) => ({ scheme: "draft", path: str.replace("draft://", "/") })), + }, + workspace: { + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, + }, + window: { + showTextDocument: vi.fn().mockResolvedValue({}), + }, + EventEmitter: vi.fn().mockImplementation(() => ({ + event: vi.fn(), + fire: vi.fn(), + })), + FileSystemProvider: { + asFileType: 1, + }, + FileType: { + File: 1, + }, + FileChangeType: { + Created: 1, + Changed: 2, + Deleted: 3, + }, + FileSystemError: { + FileNotFound: class extends Error { + constructor(uri: any) { + super(`File not found: ${uri}`) + this.name = "FileNotFound" + } + } as any, + NoPermissions: class extends Error { + constructor() { + super("No permissions") + this.name = "NoPermissions" + } + } as any, + }, + Disposable: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), +})) + +describe("createDraftTool", () => { + let mockTask: Task + let mockPushToolResult: any + let mockHandleError: any + + beforeEach(() => { + vi.clearAllMocks() + + mockPushToolResult = vi.fn() + mockHandleError = vi.fn() + + // Create a mock Task object with required properties + mockTask = { + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + recordToolUsage: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), + } as unknown as Task + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe("parameter validation", () => { + it("should error when title is missing", async () => { + await createDraftTool.execute({ title: "", content: "test content" }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("create_draft") + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("should error when content is missing (undefined)", async () => { + await createDraftTool.execute({ title: "test-title", content: undefined as any }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("create_draft") + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("should error when content is null", async () => { + await createDraftTool.execute({ title: "test-title", content: null as any }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("should error when title exceeds 255 characters", async () => { + const longTitle = "a".repeat(256) + + await createDraftTool.execute({ title: longTitle, content: "test content" }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockPushToolResult).toHaveBeenCalledWith( + formatResponse.toolError("Title must be 255 characters or less"), + ) + }) + + it("should error when content exceeds 1MB", async () => { + const largeContent = "a".repeat(1000001) + + await createDraftTool.execute({ title: "test-title", content: largeContent }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockPushToolResult).toHaveBeenCalledWith(formatResponse.toolError("Content must be 1MB or less")) + }) + }) + + describe("parseLegacy", () => { + it("should parse legacy XML parameters", () => { + const result = createDraftTool.parseLegacy({ + title: "legacy-title", + content: "legacy content", + }) + + expect(result).toEqual({ + title: "legacy-title", + content: "legacy content", + }) + }) + + it("should return empty strings for missing parameters", () => { + const result = createDraftTool.parseLegacy({}) + + expect(result).toEqual({ + title: "", + content: "", + }) + }) + }) + + describe("tool name", () => { + it("should have correct name", () => { + expect(createDraftTool.name).toBe("create_draft") + }) + }) + + describe("draft workflow integration", () => { + it("should create draft with unique content", async () => { + const mockFs = { + createAndOpen: vi.fn().mockResolvedValue("draft://my-test.md"), + } + vi.mocked(getDraftFileSystem).mockReturnValue(mockFs as any) + + await createDraftTool.execute( + { title: "my-test", content: "# My Test Document\n\nTest content." }, + mockTask, + { pushToolResult: mockPushToolResult, handleError: mockHandleError } as any, + ) + + expect(mockFs.createAndOpen).toHaveBeenCalledWith("my-test", "# My Test Document\n\nTest content.") + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("should handle multiple draft creations with different content", async () => { + const mockFs = { + createAndOpen: vi + .fn() + .mockResolvedValueOnce("draft://first.md") + .mockResolvedValueOnce("draft://second.md"), + } + vi.mocked(getDraftFileSystem).mockReturnValue(mockFs as any) + + // Create first draft + await createDraftTool.execute({ title: "first", content: "# First Draft\n\nContent one." }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + // Create second draft + await createDraftTool.execute({ title: "second", content: "# Second Draft\n\nContent two." }, mockTask, { + pushToolResult: mockPushToolResult, + handleError: mockHandleError, + } as any) + + // Verify both drafts were created with their respective content + expect(mockFs.createAndOpen).toHaveBeenCalledTimes(2) + expect(mockFs.createAndOpen).toHaveBeenCalledWith("first", "# First Draft\n\nContent one.") + expect(mockFs.createAndOpen).toHaveBeenCalledWith("second", "# Second Draft\n\nContent two.") + }) + }) +}) diff --git a/src/core/tools/helpers/draftDocumentHelpers.ts b/src/core/tools/helpers/draftDocumentHelpers.ts new file mode 100644 index 00000000000..723f8ab1070 --- /dev/null +++ b/src/core/tools/helpers/draftDocumentHelpers.ts @@ -0,0 +1,142 @@ +// kilocode_change - new file +import * as vscode from "vscode" +import { addLineNumbers } from "../../../integrations/misc/extract-text" +import { + isDraftPath, + normalizeDraftPath, + draftPathToFilename, + DRAFT_SCHEME_NAME, + getDraftFileSystem, +} from "../../../services/planning" +import type { Task } from "../../task/Task" +import type { RecordSource } from "../../context-tracking/FileContextTrackerTypes" + +export { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } + +/** + * Read a draft document and return formatted result. + * Shared helper for both ReadFileTool and simpleReadFileTool. + */ +export async function readDraftDocument( + relPath: string, + task: Task, +): Promise<{ + status: "approved" | "error" + xmlContent?: string + nativeContent?: string + error?: string +}> { + console.log("📝 [readDraftDocument] START - relPath:", relPath) + const canonicalPath = normalizeDraftPath(relPath) + const filename = draftPathToFilename(relPath) + console.log("📝 [readDraftDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) + + try { + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + console.log("📝 [readDraftDocument] constructed URI:", uri.toString()) + console.log("📝 [readDraftDocument] calling vscode.workspace.fs.readFile...") + const contentBytes = await vscode.workspace.fs.readFile(uri) + console.log("📝 [readDraftDocument] vscode.workspace.fs.readFile SUCCESS, size:", contentBytes.length) + const content = new TextDecoder().decode(contentBytes) + const numberedContent = addLineNumbers(content) + const totalLines = content.split("\n").length + console.log("📝 [readDraftDocument] decoded content, totalLines:", totalLines) + + await task.fileContextTracker.trackFileContext(canonicalPath, "read_tool" as RecordSource) + + const lineRangeAttr = ` lines="1-${totalLines}"` + const xmlInfo = totalLines > 0 ? `\n${numberedContent}\n` : `` + const nativeInfo = + totalLines > 0 + ? `File: ${canonicalPath}\nLines: 1-${totalLines}\n\n${numberedContent}` + : `File: ${canonicalPath}\n(empty file)` + + console.log("📝 [readDraftDocument] SUCCESS - returning content") + return { + status: "approved", + xmlContent: `${canonicalPath}\n${xmlInfo}`, + nativeContent: nativeInfo, + } + } catch (error) { + const isNotFoundError = error instanceof Error && error.message.includes("FileNotFound") + const errorMsg = error instanceof Error ? error.message : "Unknown error" + console.error("📝 [readDraftDocument] ERROR:", errorMsg) + if (error instanceof Error) { + console.error("📝 [readDraftDocument] ERROR stack:", error.stack) + } + + if (isNotFoundError) { + const draftName = filename.replace(/\.plan\.md$/, "").replace(/\.md$/, "") + console.log("📝 [readDraftDocument] returning FileNotFound error for draft:", draftName) + return { + status: "error", + error: `Draft document "${draftName}" does not exist. Use the create_draft tool to create it.`, + xmlContent: `${canonicalPath}Draft document "${draftName}" does not exist. Use the create_draft tool with a title and content to create a new draft document.`, + nativeContent: `File: ${canonicalPath}\nError: Draft document "${draftName}" does not exist. Use the create_draft tool with a title and content to create a new draft document.`, + } + } + + console.log("📝 [readDraftDocument] returning generic error") + return { + status: "error", + error: `Error reading draft document: ${errorMsg}`, + xmlContent: `${canonicalPath}Error reading draft document: ${errorMsg}`, + nativeContent: `File: ${canonicalPath}\nError: Error reading draft document: ${errorMsg}`, + } + } +} + +/** + * Write content to a draft document. + * Helper for WriteToFileTool. + */ +export async function writeDraftDocument( + relPath: string, + content: string, + task: Task, +): Promise<{ canonicalPath: string } | { error: string }> { + console.log("📝 [writeDraftDocument] START - relPath:", relPath, "contentLength:", content.length) + const canonicalPath = normalizeDraftPath(relPath) + const filename = draftPathToFilename(relPath) + console.log("📝 [writeDraftDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) + + try { + // Check if draft exists before writing + const draftFs = getDraftFileSystem() + console.log("📝 [writeDraftDocument] checking if draft exists...") + const wasNew = !(await draftFs.draftExists(canonicalPath)) + console.log("📝 [writeDraftDocument] draft exists check - wasNew:", wasNew) + + const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + console.log("📝 [writeDraftDocument] constructed URI:", uri.toString()) + const contentBytes = new TextEncoder().encode(content) + console.log("📝 [writeDraftDocument] calling vscode.workspace.fs.writeFile...") + await vscode.workspace.fs.writeFile(uri, contentBytes) + console.log("📝 [writeDraftDocument] vscode.workspace.fs.writeFile SUCCESS") + + // If this is a new draft document, open it in VS Code + if (wasNew) { + console.log("📝 [writeDraftDocument] opening new draft document in editor") + await vscode.window.showTextDocument(uri, { preview: false }) + } + + await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) + console.log("📝 [writeDraftDocument] SUCCESS - returning canonicalPath:", canonicalPath) + return { canonicalPath } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + console.error("📝 [writeDraftDocument] ERROR:", errorMsg) + if (error instanceof Error) { + console.error("📝 [writeDraftDocument] ERROR stack:", error.stack) + } + return { error: `Error writing draft document: ${errorMsg}` } + } +} + +/** + * Check if a path is a draft document path. + * Convenience function that re-exports from draftPaths. + */ +export function isDraftDocumentPath(path: string): boolean { + return isDraftPath(path) +} diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts index 1b41e9e9d68..e9a2760b4d8 100644 --- a/src/core/tools/simpleReadFileTool.ts +++ b/src/core/tools/simpleReadFileTool.ts @@ -13,7 +13,8 @@ import { countFileLines } from "../../integrations/misc/line-counter" import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" -import { ToolProtocol, isNativeProtocol } from "@roo-code/types" +import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" +import { isDraftPath, readDraftDocument } from "./helpers/draftDocumentHelpers" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, @@ -76,6 +77,28 @@ export async function simpleReadFileTool( const fullPath = path.resolve(cline.cwd, relPath) try { + // Check if this is a draft document + if (isDraftPath(relPath)) { + const result = await readDraftDocument(relPath, cline) + const effectiveProtocol: ToolProtocol = toolProtocol || TOOL_PROTOCOL.XML + if (result.status === "error") { + // Return error based on protocol + if (isNativeProtocol(effectiveProtocol)) { + pushToolResult(result.nativeContent || result.error || "Error reading draft document") + } else { + pushToolResult(result.xmlContent || `${result.error}`) + } + } else { + // Return result based on protocol + if (isNativeProtocol(effectiveProtocol)) { + pushToolResult(result.nativeContent || "") + } else { + pushToolResult(result.xmlContent || "") + } + } + return + } + // Check RooIgnore validation const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { diff --git a/src/extension.ts b/src/extension.ts index 57b0c50c725..37d02eb08c8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,6 +48,7 @@ import { getKiloCodeWrapperProperties } from "./core/kilocode/wrapper" // kiloco import { checkAnthropicApiKeyConflict } from "./utils/anthropicApiKeyWarning" // kilocode_change import { SettingsSyncService } from "./services/settings-sync/SettingsSyncService" // kilocode_change import { ManagedIndexer } from "./services/code-index/managed/ManagedIndexer" // kilocode_change +import { registerDraftFileSystem } from "./services/planning" // kilocode_change import { flushModels, getModels, initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { kilo_initializeSessionManager } from "./shared/kilocode/cli-sessions/extension/session-manager-utils" // kilocode_change @@ -465,6 +466,8 @@ export async function activate(context: vscode.ExtensionContext) { if (kiloCodeWrapped) { // Only foward logs in Jetbrains registerMainThreadForwardingLogger(context) + } else { + registerDraftFileSystem(context) } // Don't register the ghost provider for the CLI if (kiloCodeWrapperCode !== "cli") { diff --git a/src/services/planning/DraftFileSystemProvider.ts b/src/services/planning/DraftFileSystemProvider.ts new file mode 100644 index 00000000000..b6eb218add7 --- /dev/null +++ b/src/services/planning/DraftFileSystemProvider.ts @@ -0,0 +1,415 @@ +// kilocode_change - new file +import * as vscode from "vscode" +import * as path from "path" +import * as os from "os" +import * as fs from "fs/promises" +import { DRAFT_SCHEME_NAME, filenameToDraftPath, draftPathToFilename } from "./draftPaths" + +/** + * Generate a unique draft ID similar to Cursor's plan IDs. + * Uses 7 random hex characters for collision avoidance. + */ +function generateDraftId(): string { + const hexChars = "0123456789abcdef" + let result = "" + for (let i = 0; i < 7; i++) { + result += hexChars[Math.floor(Math.random() * 16)] + } + return result +} + +/** + * File system provider for draft:// documents. + * Stores documents on disk at ~/.kilocode/plans/ and makes them available as editor tabs. + */ +export class DraftFileSystemProvider implements vscode.FileSystemProvider { + private readonly _emitter = new vscode.EventEmitter() + private readonly _plansDir: string + + readonly onDidChangeFile: vscode.Event = this._emitter.event + + constructor() { + this._plansDir = path.join(os.homedir(), ".kilocode", "plans") + console.log("📝 [DraftFSP] constructor - plansDir:", this._plansDir) + fs.mkdir(this._plansDir, { recursive: true }) + .then(() => { + console.log("📝 [DraftFSP] constructor - plans directory created/verified") + }) + .catch((error) => { + console.error("📝 [DraftFSP] constructor - Failed to create plans directory:", error) + }) + } + + /** + * Get the real filesystem path for a draft filename. + * @param filename - The filename (e.g., "my-document.md") + * @returns The absolute path to the file on disk + */ + private getRealPath(filename: string): string { + return path.join(this._plansDir, filename) + } + + /** + * Convert a draft URI path to filename (internal storage key). + * Handles all URI variants consistently by stripping scheme and leading slashes. + * @param uri - The VS Code URI + * @returns The filename without leading slash + */ + private uriToFilename(uri: vscode.Uri): string { + // URI path always starts with / for non-empty paths + const path = uri.path.startsWith("/") ? uri.path.slice(1) : uri.path + return path + } + + /** + * Convert a filename to VS Code URI for the draft scheme. + * Always produces the canonical form: draft:/filename (single slash). + * @param filename - The filename (without leading slash) + * @returns The VS Code URI with draft:// scheme + */ + private filenameToUri(filename: string): vscode.Uri { + // Always use / prefix for the URI path - produces canonical draft:/filename + return vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + } + + watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { + // No-op: we don't support watching draft documents + return new vscode.Disposable(() => {}) + } + + async stat(uri: vscode.Uri): Promise { + const filename = this.uriToFilename(uri) + const realPath = this.getRealPath(filename) + console.log("📝 [DraftFSP] stat - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) + + try { + const stats = await fs.stat(realPath) + console.log("📝 [DraftFSP] stat - SUCCESS, size:", stats.size) + return { + type: vscode.FileType.File, + ctime: stats.birthtimeMs, + mtime: stats.mtimeMs, + size: stats.size, + } + } catch (error) { + const err = error as NodeJS.ErrnoException + console.log("📝 [DraftFSP] stat - ERROR:", err.code, err.message) + if (err.code === "ENOENT") { + throw vscode.FileSystemError.FileNotFound(uri) + } + throw vscode.FileSystemError.Unavailable(uri) + } + } + + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + // Draft documents don't support directories + throw vscode.FileSystemError.FileNotFound() + } + + createDirectory(_uri: vscode.Uri): void { + // Draft documents don't support directories + throw vscode.FileSystemError.NoPermissions() + } + + async readFile(uri: vscode.Uri): Promise { + const filename = this.uriToFilename(uri) + const realPath = this.getRealPath(filename) + console.log("📝 [DraftFSP] readFile - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) + + try { + const content = await fs.readFile(realPath) + console.log("📝 [DraftFSP] readFile - SUCCESS, size:", content.length) + return content + } catch (error) { + const err = error as NodeJS.ErrnoException + console.log("📝 [DraftFSP] readFile - ERROR:", err.code, err.message) + if (err.code === "ENOENT") { + throw vscode.FileSystemError.FileNotFound(uri) + } + throw vscode.FileSystemError.Unavailable(uri) + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: { create: boolean; overwrite: boolean }, + ): Promise { + const filename = this.uriToFilename(uri) + const realPath = this.getRealPath(filename) + console.log( + "📝 [DraftFSP] writeFile - START - uri:", + uri.toString(), + "filename:", + filename, + "realPath:", + realPath, + "contentSize:", + content.length, + ) + + // Check if file exists to determine if this is a create or update + let wasNew = false + try { + await fs.stat(realPath) + console.log("📝 [DraftFSP] writeFile - file exists, will update") + } catch { + wasNew = true + console.log("📝 [DraftFSP] writeFile - file does not exist, will create") + } + + // Ensure plans directory exists before writing + try { + await fs.mkdir(this._plansDir, { recursive: true }) + console.log("📝 [DraftFSP] writeFile - plans directory verified") + } catch (error) { + console.error("📝 [DraftFSP] writeFile - ERROR creating plans directory:", error) + throw error + } + + // Write to disk + try { + await fs.writeFile(realPath, content) + console.log("📝 [DraftFSP] writeFile - SUCCESS writing to disk") + } catch (error) { + const err = error as NodeJS.ErrnoException + console.error("📝 [DraftFSP] writeFile - ERROR writing file:", err.code, err.message, err.stack) + throw error + } + + // Emit file change event with canonical URI + const canonicalUri = this.filenameToUri(filename) + const event: vscode.FileChangeEvent = { + type: wasNew ? vscode.FileChangeType.Created : vscode.FileChangeType.Changed, + uri: canonicalUri, + } + this._emitter.fire([event]) + console.log("📝 [DraftFSP] writeFile - fired event, type:", wasNew ? "Created" : "Changed") + } + + async delete(uri: vscode.Uri): Promise { + const filename = this.uriToFilename(uri) + const realPath = this.getRealPath(filename) + + try { + await fs.unlink(realPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw vscode.FileSystemError.FileNotFound(uri) + } + throw vscode.FileSystemError.Unavailable(uri) + } + + // Emit file change event with canonical URI + const canonicalUri = this.filenameToUri(filename) + const event: vscode.FileChangeEvent = { + type: vscode.FileChangeType.Deleted, + uri: canonicalUri, + } + this._emitter.fire([event]) + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void { + // Draft documents don't support rename + throw vscode.FileSystemError.NoPermissions() + } + + /** + * Create a new draft document and open it in the editor. + * @param name - The name/title of the document (will be used as filename with unique ID suffix) + * @param content - Initial content of the document + * @returns The draft:// URI path (e.g., "draft://filename_7313f09d.plan.md") + */ + async createAndOpen(name: string, content: string): Promise { + // Generate unique draft ID and ensure name has .plan.md extension + const draftId = generateDraftId() + let baseName = name + if (name.endsWith(".plan.md")) { + baseName = name.slice(0, -8) // Remove ".plan.md" + } + const filename = `${baseName}_${draftId}.plan.md` + console.log( + "📝 [DraftFSP] createAndOpen - START - name:", + name, + "draftId:", + draftId, + "filename:", + filename, + "contentLength:", + content.length, + ) + + // Ensure plans directory exists + try { + await fs.mkdir(this._plansDir, { recursive: true }) + console.log("📝 [DraftFSP] createAndOpen - plans directory verified") + } catch (error) { + console.error("📝 [DraftFSP] createAndOpen - ERROR creating plans directory:", error) + throw error + } + + // Store the document on disk + const contentBytes = new TextEncoder().encode(content) + const realPath = this.getRealPath(filename) + console.log("📝 [DraftFSP] createAndOpen - writing to realPath:", realPath) + try { + await fs.writeFile(realPath, contentBytes) + console.log("📝 [DraftFSP] createAndOpen - SUCCESS writing to disk") + } catch (error) { + const err = error as NodeJS.ErrnoException + console.error("📝 [DraftFSP] createAndOpen - ERROR writing file:", err.code, err.message, err.stack) + throw error + } + + // Create URI using consistent formatting + const uri = this.filenameToUri(filename) + console.log("📝 [DraftFSP] createAndOpen - created URI:", uri.toString()) + + // Emit file change event + const event: vscode.FileChangeEvent = { + type: vscode.FileChangeType.Created, + uri, + } + this._emitter.fire([event]) + console.log("📝 [DraftFSP] createAndOpen - fired Created event") + + // Open document in VS Code editor + await vscode.window.showTextDocument(uri, { preview: false }) + console.log("📝 [DraftFSP] createAndOpen - opened document in editor") + + // Return the draft:// path for use in tools + const result = filenameToDraftPath(filename) + console.log("📝 [DraftFSP] createAndOpen - returning draftPath:", result) + return result + } + + /** + * Get draft content for RPC access. + * @param draftPath - The draft path (e.g., "draft://filename.md") + * @returns Content as Uint8Array, or undefined if not found + */ + async getDraftContent(draftPath: string): Promise { + const filename = draftPathToFilename(draftPath) + const realPath = this.getRealPath(filename) + + try { + const buffer = await fs.readFile(realPath) + return new Uint8Array(buffer) + } catch { + return undefined + } + } + + /** + * Set draft content from RPC (user edits from JetBrains). + * @param draftPath - The draft path + * @param content - New content + */ + async setDraftContent(draftPath: string, content: Uint8Array): Promise { + const filename = draftPathToFilename(draftPath) + const realPath = this.getRealPath(filename) + + // Check if file exists to determine if this is a create or update + let wasNew = false + try { + await fs.stat(realPath) + } catch { + wasNew = true + } + + // Ensure plans directory exists + await fs.mkdir(this._plansDir, { recursive: true }) + + // Write to disk + await fs.writeFile(realPath, content) + + const uri = this.filenameToUri(filename) + this._emitter.fire([ + { + type: wasNew ? vscode.FileChangeType.Created : vscode.FileChangeType.Changed, + uri, + }, + ]) + } + + /** + * Check if a draft exists. + * @param draftPath - The draft path + * @returns true if exists + */ + async draftExists(draftPath: string): Promise { + const filename = draftPathToFilename(draftPath) + const realPath = this.getRealPath(filename) + console.log("📝 [DraftFSP] draftExists - draftPath:", draftPath, "filename:", filename, "realPath:", realPath) + + try { + await fs.stat(realPath) + console.log("📝 [DraftFSP] draftExists - file exists") + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + console.log("📝 [DraftFSP] draftExists - file does not exist, error:", err.code) + return false + } + } + + /** + * Delete a draft. + * @param draftPath - The draft path + */ + async deleteDraft(draftPath: string): Promise { + const filename = draftPathToFilename(draftPath) + const realPath = this.getRealPath(filename) + + try { + await fs.unlink(realPath) + const uri = this.filenameToUri(filename) + this._emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]) + } catch { + // Ignore errors - file may not exist + } + } + + /** + * List all draft paths. + * @returns Array of draft:// paths + */ + async listDrafts(): Promise { + try { + await fs.mkdir(this._plansDir, { recursive: true }) + const files = await fs.readdir(this._plansDir) + return files.filter((file) => file.endsWith(".plan.md")).map((filename) => filenameToDraftPath(filename)) + } catch { + return [] + } + } +} + +// Singleton instance +let draftFileSystemProvider: DraftFileSystemProvider | undefined + +/** + * Get the singleton draft file system provider instance. + */ +export function getDraftFileSystem(): DraftFileSystemProvider { + if (!draftFileSystemProvider) { + draftFileSystemProvider = new DraftFileSystemProvider() + } + return draftFileSystemProvider +} + +/** + * Register the draft file system provider with VS Code. + * @param context - VS Code extension context + */ +export function registerDraftFileSystem(context: vscode.ExtensionContext): void { + const provider = getDraftFileSystem() + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(DRAFT_SCHEME_NAME, provider, { + isCaseSensitive: true, + }), + ) + + // Note: We no longer delete drafts when tabs close - they persist on disk + // Users can manually delete them if needed +} diff --git a/src/services/planning/__tests__/draftFileSystemProvider.spec.ts b/src/services/planning/__tests__/draftFileSystemProvider.spec.ts new file mode 100644 index 00000000000..0a1c764c318 --- /dev/null +++ b/src/services/planning/__tests__/draftFileSystemProvider.spec.ts @@ -0,0 +1,488 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { DraftFileSystemProvider } from "../DraftFileSystemProvider" +import { DRAFT_SCHEME_NAME } from "../draftPaths" +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" + +// Mock os module +let mockHomedir: string +vi.mock("os", async () => { + const actual = await vi.importActual("os") + return { + ...actual, + homedir: () => mockHomedir, + } +}) +import * as os from "os" + +// Mock VS Code +vi.mock("vscode", () => ({ + Uri: { + parse: vi.fn((str) => ({ + scheme: "draft", + path: str.replace("draft://", "/"), + })), + }, + workspace: { + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, + }, + window: { + showTextDocument: vi.fn().mockResolvedValue({}), + }, + EventEmitter: class MockEventEmitter { + private _event = vi.fn() + event = this._event + fire = vi.fn() + dispose = vi.fn() + }, + FileSystemProvider: { + asFileType: 1, + }, + FileType: { + File: 1, + }, + FileChangeType: { + Created: 1, + Changed: 2, + Deleted: 3, + }, + FileSystemError: { + FileNotFound: class FileNotFound extends Error { + constructor(uri?: any) { + super(`File not found: ${uri || ""}`) + this.name = "FileNotFound" + } + }, + NoPermissions: class NoPermissions extends Error { + constructor() { + super("No permissions") + this.name = "NoPermissions" + } + }, + }, + Disposable: class Disposable { + private _disposeFn: () => void + constructor(disposeFn?: () => void) { + this._disposeFn = disposeFn || (() => {}) + } + dispose() { + this._disposeFn() + } + }, +})) + +describe("DraftFileSystemProvider", () => { + let provider: DraftFileSystemProvider + let tempDir: string + + beforeEach(async () => { + vi.clearAllMocks() + // Create a temporary directory for test files + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "draft-fsp-test-")) + // Set mock homedir to return our temp directory + mockHomedir = tempDir + // Get a fresh instance for each test + provider = new DraftFileSystemProvider() + }) + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + vi.restoreAllMocks() + }) + + describe("createAndOpen", () => { + it("should create a new draft document with correct content", async () => { + const content = "# Test Document\n\nThis is a test." + const result = await provider.createAndOpen("test-doc", content) + + expect(result).toMatch(/^draft:\/\/test-doc_[a-f0-9]{7}\.plan\.md$/) + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("should append .plan.md extension if not present", async () => { + const result = await provider.createAndOpen("my-document", "# Content") + + expect(result).toMatch(/^draft:\/\/my-document_[a-f0-9]{7}\.plan\.md$/) + }) + + it("should preserve .plan.md extension if already present", async () => { + const result = await provider.createAndOpen("existing.plan.md", "# Content") + + expect(result).toMatch(/^draft:\/\/existing_[a-f0-9]{7}\.plan\.md$/) + }) + + it("should store content that can be read back", async () => { + const content = "# My Draft\n\nSome content here." + const draftPath = await provider.createAndOpen("my-draft", content) + + const uri = vscode.Uri.parse(draftPath) + const readContent = await provider.readFile(uri) + const decodedContent = new TextDecoder().decode(readContent) + + expect(decodedContent).toBe(content) + }) + + it("should emit Created event when document is created", async () => { + const emitter = new vscode.EventEmitter() + const content = "# Test" + await provider.createAndOpen("test", content) + + // The provider should have fired the event + expect(emitter.fire).toBeDefined() + }) + }) + + describe("readFile", () => { + it("should return content for existing document", async () => { + const content = "# Test Content" + const draftPath = await provider.createAndOpen("existing", content) + + const uri = vscode.Uri.parse(draftPath) + const readContent = await provider.readFile(uri) + const decodedContent = new TextDecoder().decode(readContent) + + expect(decodedContent).toBe(content) + }) + + it("should throw FileNotFound for non-existent document", async () => { + const uri = vscode.Uri.parse("draft:///nonexistent.plan.md") + + await expect(provider.readFile(uri)).rejects.toThrow() + }) + + it("should handle path with leading slash", async () => { + const content = "# Content" + const draftPath = await provider.createAndOpen("path-test", content) + + const uri = vscode.Uri.parse(draftPath) + const readContent = await provider.readFile(uri) + const decodedContent = new TextDecoder().decode(readContent) + + expect(decodedContent).toBe(content) + }) + }) + + describe("writeFile", () => { + it("should update existing document content", async () => { + const originalContent = "# Original" + const draftPath = await provider.createAndOpen("updatable", originalContent) + + const newContent = "# Updated Content" + const uri = vscode.Uri.parse(draftPath) + await provider.writeFile(uri, new TextEncoder().encode(newContent), { + create: false, + overwrite: true, + }) + + const readContent = await provider.readFile(uri) + const decodedContent = new TextDecoder().decode(readContent) + + expect(decodedContent).toBe(newContent) + }) + + it("should emit Changed event when document is updated", async () => { + const content = "# Original" + const draftPath = await provider.createAndOpen("change-test", content) + + const newContent = "# Changed" + const uri = vscode.Uri.parse(draftPath) + await provider.writeFile(uri, new TextEncoder().encode(newContent), { + create: false, + overwrite: true, + }) + + // Event should have been fired + expect(provider.onDidChangeFile).toBeDefined() + }) + }) + + describe("delete", () => { + it("should remove document from storage", async () => { + const content = "# To Delete" + const draftPath = await provider.createAndOpen("delete-me", content) + + const uri = vscode.Uri.parse(draftPath) + await provider.delete(uri) + + // Should throw FileNotFound after deletion + await expect(provider.readFile(uri)).rejects.toThrow() + }) + + it("should emit Deleted event when document is deleted", async () => { + const content = "# Test" + const draftPath = await provider.createAndOpen("emit-test", content) + + const uri = vscode.Uri.parse(draftPath) + await provider.delete(uri) + + // Event should have been fired + expect(provider.onDidChangeFile).toBeDefined() + }) + + it("should throw FileNotFound for non-existent document", async () => { + const uri = vscode.Uri.parse("draft:///never-existed.plan.md") + + await expect(provider.delete(uri)).rejects.toThrow() + }) + }) + + describe("content isolation", () => { + it("should maintain separate content for each draft", async () => { + const content1 = "# Draft 1\n\nContent of first draft." + const content2 = "# Draft 2\n\nDifferent content." + + const draftPath1 = await provider.createAndOpen("draft-1", content1) + const draftPath2 = await provider.createAndOpen("draft-2", content2) + + const uri1 = vscode.Uri.parse(draftPath1) + const uri2 = vscode.Uri.parse(draftPath2) + + const read1 = new TextDecoder().decode(await provider.readFile(uri1)) + const read2 = new TextDecoder().decode(await provider.readFile(uri2)) + + expect(read1).toBe(content1) + expect(read2).toBe(content2) + expect(read1).not.toBe(read2) + }) + + it("should allow updating one draft without affecting others", async () => { + const content1 = "# Original 1" + const content2 = "# Original 2" + + const draftPath1 = await provider.createAndOpen("first", content1) + const draftPath2 = await provider.createAndOpen("second", content2) + + // Update only first draft + const updatedContent = "# Updated First" + const uri1 = vscode.Uri.parse(draftPath1) + await provider.writeFile(uri1, new TextEncoder().encode(updatedContent), { + create: false, + overwrite: true, + }) + + // Verify first draft is updated + const read1 = new TextDecoder().decode(await provider.readFile(uri1)) + expect(read1).toBe(updatedContent) + + // Verify second draft is unchanged + const uri2 = vscode.Uri.parse(draftPath2) + const read2 = new TextDecoder().decode(await provider.readFile(uri2)) + expect(read2).toBe(content2) + }) + + it("should isolate drafts with similar names", async () => { + const contentA = "# Document A" + const contentB = "# Document B" + + const draftPathA = await provider.createAndOpen("doc", contentA) + const draftPathB = await provider.createAndOpen("doc-2", contentB) + + const uriA = vscode.Uri.parse(draftPathA) + const uriB = vscode.Uri.parse(draftPathB) + + const readA = new TextDecoder().decode(await provider.readFile(uriA)) + const readB = new TextDecoder().decode(await provider.readFile(uriB)) + + expect(readA).toBe(contentA) + expect(readB).toBe(contentB) + }) + }) + + describe("stat", () => { + it("should return FileStat for existing document", async () => { + const content = "# Test" + const draftPath = await provider.createAndOpen("stat-test", content) + + const uri = vscode.Uri.parse(draftPath) + const stat = await provider.stat(uri) + + expect(stat.type).toBe(vscode.FileType.File) + expect(stat.size).toBeGreaterThan(0) + expect(stat.ctime).toBeDefined() + expect(stat.mtime).toBeDefined() + }) + + it("should throw FileNotFound for non-existent document", async () => { + const uri = vscode.Uri.parse("draft:///stat-missing.plan.md") + + await expect(provider.stat(uri)).rejects.toThrow() + }) + }) + + describe("watch", () => { + it("should return a disposable", () => { + const uri = vscode.Uri.parse("draft:///test.plan.md") + const disposable = provider.watch(uri, { recursive: true, excludes: [] }) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("readDirectory", () => { + it("should throw FileNotFound (directories not supported)", () => { + const uri = vscode.Uri.parse("draft:///") + + expect(() => provider.readDirectory(uri)).toThrow() + }) + }) + + describe("createDirectory", () => { + it("should throw NoPermissions (directories not supported)", () => { + const uri = vscode.Uri.parse("draft:///new-dir") + + expect(() => provider.createDirectory(uri)).toThrow() + }) + }) + + describe("rename", () => { + it("should throw NoPermissions (rename not supported)", () => { + const oldUri = vscode.Uri.parse("draft:///old.plan.md") + const newUri = vscode.Uri.parse("draft:///new.plan.md") + + expect(() => provider.rename(oldUri, newUri, { overwrite: true })).toThrow() + }) + }) + + describe("getDraftContent", () => { + it("should return correct content for existing draft", async () => { + const content = new TextEncoder().encode("# Test Content") + await provider.setDraftContent("draft://test-doc.plan.md", content) + + const result = await provider.getDraftContent("draft://test-doc.plan.md") + + expect(result).toBeDefined() + expect(result).toEqual(content) + }) + + it("should return undefined for non-existent draft", async () => { + const result = await provider.getDraftContent("draft://nonexistent.plan.md") + + expect(result).toBeUndefined() + }) + + it("should handle draft path with triple slashes", async () => { + const content = new TextEncoder().encode("# Content") + await provider.setDraftContent("draft:///path-test.plan.md", content) + + const result = await provider.getDraftContent("draft:///path-test.plan.md") + + expect(result).toEqual(content) + }) + }) + + describe("setDraftContent", () => { + it("should update existing draft content", async () => { + const originalContent = new TextEncoder().encode("# Original") + const newContent = new TextEncoder().encode("# Updated") + await provider.setDraftContent("draft://updatable.plan.md", originalContent) + + await provider.setDraftContent("draft://updatable.plan.md", newContent) + + const result = await provider.getDraftContent("draft://updatable.plan.md") + expect(result).toEqual(newContent) + }) + + it("should create new draft when content does not exist", async () => { + const content = new TextEncoder().encode("# New Content") + await provider.setDraftContent("draft://new-doc.plan.md", content) + + const result = await provider.getDraftContent("draft://new-doc.plan.md") + expect(result).toEqual(content) + }) + }) + + describe("draftExists", () => { + it("should return true for existing draft", async () => { + const content = new TextEncoder().encode("# Test") + await provider.setDraftContent("draft://existing.plan.md", content) + + const result = await provider.draftExists("draft://existing.plan.md") + + expect(result).toBe(true) + }) + + it("should return false for non-existent draft", async () => { + const result = await provider.draftExists("draft://never-existed.plan.md") + + expect(result).toBe(false) + }) + + it("should return false after draft is deleted", async () => { + const content = new TextEncoder().encode("# To Delete") + await provider.setDraftContent("draft://delete-test.plan.md", content) + await provider.deleteDraft("draft://delete-test.plan.md") + + const result = await provider.draftExists("draft://delete-test.plan.md") + expect(result).toBe(false) + }) + }) + + describe("deleteDraft", () => { + it("should remove draft from storage", async () => { + const content = new TextEncoder().encode("# To Delete") + await provider.setDraftContent("draft://delete-me.plan.md", content) + + await provider.deleteDraft("draft://delete-me.plan.md") + + const result = await provider.getDraftContent("draft://delete-me.plan.md") + expect(result).toBeUndefined() + }) + + it("should be no-op for non-existent draft", async () => { + // Should not throw + await expect(provider.deleteDraft("draft://never-existed.plan.md")).resolves.not.toThrow() + + // Verify no drafts were added + const drafts = await provider.listDrafts() + expect(drafts).toHaveLength(0) + }) + }) + + describe("listDrafts", () => { + it("should return all draft paths", async () => { + await provider.setDraftContent("draft://doc1.plan.md", new TextEncoder().encode("# Doc 1")) + await provider.setDraftContent("draft://doc2.plan.md", new TextEncoder().encode("# Doc 2")) + await provider.setDraftContent("draft://doc3.plan.md", new TextEncoder().encode("# Doc 3")) + + const result = await provider.listDrafts() + + expect(result).toHaveLength(3) + expect(result).toContain("draft://doc1.plan.md") + expect(result).toContain("draft://doc2.plan.md") + expect(result).toContain("draft://doc3.plan.md") + }) + + it("should return empty array when no drafts exist", async () => { + const result = await provider.listDrafts() + + expect(result).toEqual([]) + }) + + it("should reflect drafts created via setDraftContent", async () => { + await provider.setDraftContent("draft://new.plan.md", new TextEncoder().encode("# New")) + + const result = await provider.listDrafts() + + expect(result).toContain("draft://new.plan.md") + }) + + it("should reflect drafts deleted via deleteDraft", async () => { + await provider.setDraftContent("draft://to-remove.plan.md", new TextEncoder().encode("# Remove")) + await provider.deleteDraft("draft://to-remove.plan.md") + + const result = await provider.listDrafts() + + expect(result).not.toContain("draft://to-remove.plan.md") + }) + }) +}) diff --git a/src/services/planning/__tests__/draftPaths.spec.ts b/src/services/planning/__tests__/draftPaths.spec.ts new file mode 100644 index 00000000000..2d2af7a82b2 --- /dev/null +++ b/src/services/planning/__tests__/draftPaths.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest" +import { + DRAFT_PROTOCOL, + DRAFT_SCHEME_NAME, + isDraftPath, + draftPathToFilename, + filenameToDraftPath, + normalizeDraftPath, +} from "../draftPaths" + +describe("draftPaths", () => { + describe("DRAFT_PROTOCOL", () => { + it("should equal 'draft://'", () => { + expect(DRAFT_PROTOCOL).toBe("draft://") + }) + }) + + describe("DRAFT_SCHEME_NAME", () => { + it("should equal 'draft'", () => { + expect(DRAFT_SCHEME_NAME).toBe("draft") + }) + }) + + describe("isDraftPath", () => { + it("should return true for draft:// paths (canonical format)", () => { + expect(isDraftPath("draft://test.md")).toBe(true) + expect(isDraftPath("draft://my-document")).toBe(true) + }) + + it("should return true for draft:/// paths (AI triple-slash format)", () => { + expect(isDraftPath("draft:///test.md")).toBe(true) + expect(isDraftPath("draft:///path/to/file.md")).toBe(true) + }) + + it("should return true for draft:/ paths (VSCode normalized format)", () => { + expect(isDraftPath("draft:/test.md")).toBe(true) + expect(isDraftPath("draft:/implementation-plan.md")).toBe(true) + expect(isDraftPath("draft:/path/to/file.md")).toBe(true) + }) + + it("should return false for non-draft paths", () => { + expect(isDraftPath("/path/to/file.md")).toBe(false) + expect(isDraftPath("file://path/to/file.md")).toBe(false) + expect(isDraftPath("test.md")).toBe(false) + }) + }) + + describe("normalizeDraftPath", () => { + it("should normalize draft:// paths (already canonical)", () => { + expect(normalizeDraftPath("draft://test.md")).toBe("draft://test.md") + expect(normalizeDraftPath("draft://path/to/file.md")).toBe("draft://path/to/file.md") + }) + + it("should normalize draft:/// paths (AI triple-slash format)", () => { + expect(normalizeDraftPath("draft:///test.md")).toBe("draft://test.md") + expect(normalizeDraftPath("draft:///implementation-plan.md")).toBe("draft://implementation-plan.md") + }) + + it("should normalize draft:/ paths (VSCode normalized format)", () => { + expect(normalizeDraftPath("draft:/test.md")).toBe("draft://test.md") + expect(normalizeDraftPath("draft:/implementation-plan.md")).toBe("draft://implementation-plan.md") + }) + + it("should throw error for non-draft paths", () => { + expect(() => normalizeDraftPath("test.md")).toThrow("Invalid draft path: test.md") + expect(() => normalizeDraftPath("/path/to/file.md")).toThrow() + }) + }) + + describe("draftPathToFilename", () => { + it("should extract filename from draft:// path (canonical)", () => { + expect(draftPathToFilename("draft://test.md")).toBe("test.md") + expect(draftPathToFilename("draft://my-document.md")).toBe("my-document.md") + }) + + it("should extract filename from draft:/// path (AI format)", () => { + expect(draftPathToFilename("draft:///test.md")).toBe("test.md") + expect(draftPathToFilename("draft:///path/to/file.md")).toBe("path/to/file.md") + }) + + it("should extract filename from draft:/ path (VSCode normalized)", () => { + expect(draftPathToFilename("draft:/test.md")).toBe("test.md") + expect(draftPathToFilename("draft:/implementation-plan.md")).toBe("implementation-plan.md") + expect(draftPathToFilename("draft:/path/to/file.md")).toBe("path/to/file.md") + }) + + it("should throw error for invalid draft paths", () => { + expect(() => draftPathToFilename("test.md")).toThrow("Invalid draft path: test.md") + expect(() => draftPathToFilename("/path/to/file.md")).toThrow() + }) + }) + + describe("filenameToDraftPath", () => { + it("should convert filename to canonical draft:// path", () => { + expect(filenameToDraftPath("test.md")).toBe("draft://test.md") + expect(filenameToDraftPath("my-document")).toBe("draft://my-document") + }) + + it("should strip leading slashes from filename", () => { + expect(filenameToDraftPath("/test.md")).toBe("draft://test.md") + expect(filenameToDraftPath("//test.md")).toBe("draft://test.md") + expect(filenameToDraftPath("///test.md")).toBe("draft://test.md") + }) + + it("should handle paths with directories", () => { + expect(filenameToDraftPath("path/to/file.md")).toBe("draft://path/to/file.md") + expect(filenameToDraftPath("/path/to/file.md")).toBe("draft://path/to/file.md") + }) + }) + + describe("roundtrip conversion", () => { + it("should maintain path integrity through roundtrip", () => { + const original = "test.md" + const draftPath = filenameToDraftPath(original) + const result = draftPathToFilename(draftPath) + expect(result).toBe(original) + }) + + it("should handle complex paths through roundtrip", () => { + const original = "path/to/my-document.md" + const draftPath = filenameToDraftPath(original) + expect(draftPath).toBe("draft://path/to/my-document.md") + const result = draftPathToFilename(draftPath) + expect(result).toBe(original) + }) + + it("should normalize any variant through roundtrip", () => { + // AI gives us triple-slash + const aiPath = "draft:///implementation-plan.md" + const normalized = normalizeDraftPath(aiPath) + expect(normalized).toBe("draft://implementation-plan.md") + + // VSCode gives us single-slash + const vscodePath = "draft:/implementation-plan.md" + const normalizedVscode = normalizeDraftPath(vscodePath) + expect(normalizedVscode).toBe("draft://implementation-plan.md") + + // Both extract same filename + expect(draftPathToFilename(aiPath)).toBe("implementation-plan.md") + expect(draftPathToFilename(vscodePath)).toBe("implementation-plan.md") + }) + }) +}) diff --git a/src/services/planning/draftPaths.ts b/src/services/planning/draftPaths.ts new file mode 100644 index 00000000000..0c83d5649b8 --- /dev/null +++ b/src/services/planning/draftPaths.ts @@ -0,0 +1,100 @@ +// kilocode_change - new file +/** + * Draft path utilities - single source of truth for all draft:// path handling. + * + * URI Format Background: + * - Standard URI: scheme://authority/path + * - For schemes without authority (like draft), the format is: scheme:///path or scheme:/path + * - VSCode's Uri.parse() normalizes "draft:///file.md" to "draft:/file.md" (single slash + path) + * + * Our Standard Format: + * - User-facing/canonical: "draft://filename.md" (looks familiar, clean) + * - Internal (after Uri.parse): "draft:/filename.md" (VSCode normalized) + * + * This module handles all conversions transparently. + */ + +/** + * Draft scheme name for VSCode file system registration. + * Use this when registering the FileSystemProvider. + */ +export const DRAFT_SCHEME_NAME = "draft" + +/** + * Draft protocol prefix for user-facing paths. + * This is the canonical format: "draft://filename.md" + */ +export const DRAFT_PROTOCOL = "draft://" + +/** + * Check if a path is a draft path. + * Handles all variants: draft://file.md, draft:///file.md, draft:/file.md + * + * @param path - The path to check + * @returns true if the path is a draft path + */ +export function isDraftPath(path: string): boolean { + return path.startsWith(`${DRAFT_SCHEME_NAME}:`) +} + +/** + * Normalize any draft path variant to the canonical format: "draft://filename.md" + * + * Handles: + * - "draft://file.md" -> "draft://file.md" (already canonical) + * - "draft:///file.md" -> "draft://file.md" (triple slash from AI) + * - "draft:/file.md" -> "draft://file.md" (VSCode normalized) + * + * @param draftPath - Any draft path variant + * @returns Canonical draft path: "draft://filename.md" + * @throws Error if not a valid draft path + */ +export function normalizeDraftPath(draftPath: string): string { + if (!isDraftPath(draftPath)) { + throw new Error(`Invalid draft path: ${draftPath}`) + } + + // Extract filename by removing scheme and all leading slashes + const afterScheme = draftPath.slice(`${DRAFT_SCHEME_NAME}:`.length) + const filename = afterScheme.replace(/^\/+/, "") + + // Return canonical format + return `${DRAFT_PROTOCOL}${filename}` +} + +/** + * Extract filename from a draft path. + * Handles all variants: draft://file.md, draft:///file.md, draft:/file.md + * + * @param draftPath - The draft path (any variant) + * @returns The filename without protocol or leading slashes (e.g., "filename.md") + * @throws Error if the path is not a valid draft path + */ +export function draftPathToFilename(draftPath: string): string { + if (!isDraftPath(draftPath)) { + throw new Error(`Invalid draft path: ${draftPath}`) + } + + // Remove scheme prefix + const afterScheme = draftPath.slice(`${DRAFT_SCHEME_NAME}:`.length) + // Remove any leading slashes (handles //, ///, or /) + const result = afterScheme.replace(/^\/+/, "") + console.log(`📝 [draftPaths] draftPathToFilename: "${draftPath}" -> "${result}"`) + return result +} + +/** + * Convert a filename to the canonical draft:// path. + * Always returns clean "draft://filename.md" format. + * + * @param filename - The filename (e.g., "filename.md" or "/filename.md") + * @returns The draft path in canonical format (e.g., "draft://filename.md") + */ +export function filenameToDraftPath(filename: string): string { + // Remove any leading slashes from filename + const cleanFilename = filename.replace(/^\/+/, "") + // Return canonical format: draft://filename.md + const result = `${DRAFT_PROTOCOL}${cleanFilename}` + console.log(`📝 [draftPaths] filenameToDraftPath: "${filename}" -> "${result}"`) + return result +} diff --git a/src/services/planning/index.ts b/src/services/planning/index.ts new file mode 100644 index 00000000000..e72facf10d2 --- /dev/null +++ b/src/services/planning/index.ts @@ -0,0 +1,10 @@ +// kilocode_change - new file +export { getDraftFileSystem, registerDraftFileSystem, DraftFileSystemProvider } from "./DraftFileSystemProvider" +export { + DRAFT_SCHEME_NAME, + DRAFT_PROTOCOL, + isDraftPath, + draftPathToFilename, + filenameToDraftPath, + normalizeDraftPath, +} from "./draftPaths" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 56dd4624bb8..6a6dfd2a7bc 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -121,6 +121,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + create_draft: { title: string; content: string } // Add more tools as they are migrated to native protocol } @@ -262,6 +263,11 @@ export interface GenerateImageToolUse extends ToolUse<"generate_image"> { params: Partial, "prompt" | "path" | "image">> } +export interface CreateDraftToolUse extends ToolUse<"create_draft"> { + name: "create_draft" + params: Partial, "title" | "content">> +} + // Define tool group configuration export type ToolGroupConfig = { tools: readonly string[] @@ -298,6 +304,7 @@ export const TOOL_DISPLAY_NAMES: Record = { update_todo_list: "update todo list", run_slash_command: "run slash command", generate_image: "generate images", + create_draft: "create draft documents", } as const // Define available tool groups. @@ -313,6 +320,7 @@ export const TOOL_GROUPS: Record = { "delete_file", // kilocode_change "new_rule", // kilocode_change "generate_image", + "create_draft", ], customTools: ["search_and_replace", "search_replace", "apply_patch"], }, @@ -338,7 +346,8 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "switch_mode", "new_task", "report_bug", - "condense", // kilocode_Change + "condense", // kilocode_change + "create_draft", // kilocode_change "update_todo_list", "run_slash_command", ] as const From c3cec82dc9ac39db52343ac3342c5ceac13d6bc9 Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Wed, 7 Jan 2026 16:05:55 -0800 Subject: [PATCH 2/5] feat(planning): rename create_draft tool to create_plan and update functionality - Replaced the create_draft tool with create_plan to better reflect its purpose of creating ephemeral planning documents. - Updated all references in the codebase, including imports, function calls, and documentation. - Enhanced the tool's description to clarify its usage for brainstorming and structured thinking. - Removed the previous draft management system and integrated the new plan management into the existing file system provider. - Updated tests and snapshots to ensure compatibility with the new tool name and functionality. This change improves clarity and aligns the tool's name with its intended use, enhancing the user experience in planning tasks. --- .changeset/planning-doc-tool.md | 4 +- packages/types/src/tool.ts | 2 +- src/__tests__/extension.spec.ts | 9 + .../presentAssistantMessage.ts | 8 +- src/core/kilocode/wrapper.ts | 4 +- .../architect-mode-prompt.snap | 75 ++++-- .../ask-mode-prompt.snap | 54 ++++- .../mcp-server-creation-disabled.snap | 54 ++++- .../mcp-server-creation-enabled.snap | 54 ++++- .../partial-reads-enabled.snap | 54 ++++- .../consistent-system-prompt.snap | 78 ++++++ .../with-computer-use-support.snap | 78 ++++++ .../with-diff-enabled-false.snap | 78 ++++++ .../system-prompt/with-diff-enabled-true.snap | 78 ++++++ .../with-diff-enabled-undefined.snap | 78 ++++++ .../with-different-viewport-size.snap | 78 ++++++ .../system-prompt/with-mcp-hub-provided.snap | 78 ++++++ .../system-prompt/with-undefined-mcp-hub.snap | 78 ++++++ src/core/prompts/tools/create-draft.ts | 50 ---- src/core/prompts/tools/create-plan.ts | 82 +++++++ .../prompts/tools/filter-tools-for-mode.ts | 10 +- src/core/prompts/tools/index.ts | 8 +- .../tools/native-tools/create_draft.ts | 47 ---- .../prompts/tools/native-tools/create_plan.ts | 79 ++++++ src/core/prompts/tools/native-tools/index.ts | 4 +- src/core/tools/ApplyDiffTool.ts | 46 ++-- .../{CreateDraftTool.ts => CreatePlanTool.ts} | 54 ++--- src/core/tools/ReadFileTool.ts | 16 +- src/core/tools/SearchAndReplaceTool.ts | 26 +- src/core/tools/WriteToFileTool.ts | 43 +++- ...aftTool.spec.ts => createPlanTool.spec.ts} | 62 ++--- .../tools/helpers/draftDocumentHelpers.ts | 142 ----------- src/core/tools/helpers/planDocumentHelpers.ts | 161 +++++++++++++ src/core/tools/simpleReadFileTool.ts | 8 +- src/extension.ts | 6 +- ...mProvider.ts => PlanFileSystemProvider.ts} | 172 ++++++------- .../planning/__tests__/draftPaths.spec.ts | 143 ----------- ...spec.ts => planFileSystemProvider.spec.ts} | 228 +++++++++--------- .../planning/__tests__/planPaths.spec.ts | 143 +++++++++++ src/services/planning/draftPaths.ts | 100 -------- src/services/planning/index.ts | 16 +- src/services/planning/planPaths.ts | 123 ++++++++++ src/shared/tools.ts | 12 +- 43 files changed, 1821 insertions(+), 902 deletions(-) delete mode 100644 src/core/prompts/tools/create-draft.ts create mode 100644 src/core/prompts/tools/create-plan.ts delete mode 100644 src/core/prompts/tools/native-tools/create_draft.ts create mode 100644 src/core/prompts/tools/native-tools/create_plan.ts rename src/core/tools/{CreateDraftTool.ts => CreatePlanTool.ts} (53%) rename src/core/tools/__tests__/{createDraftTool.spec.ts => createPlanTool.spec.ts} (71%) delete mode 100644 src/core/tools/helpers/draftDocumentHelpers.ts create mode 100644 src/core/tools/helpers/planDocumentHelpers.ts rename src/services/planning/{DraftFileSystemProvider.ts => PlanFileSystemProvider.ts} (59%) delete mode 100644 src/services/planning/__tests__/draftPaths.spec.ts rename src/services/planning/__tests__/{draftFileSystemProvider.spec.ts => planFileSystemProvider.spec.ts} (55%) create mode 100644 src/services/planning/__tests__/planPaths.spec.ts delete mode 100644 src/services/planning/draftPaths.ts create mode 100644 src/services/planning/planPaths.ts diff --git a/.changeset/planning-doc-tool.md b/.changeset/planning-doc-tool.md index c892212ac23..b399ba77e87 100644 --- a/.changeset/planning-doc-tool.md +++ b/.changeset/planning-doc-tool.md @@ -1,5 +1,5 @@ --- -"kilo-code": patch +"kilo-code": minor --- -Add `create_draft` tool for creating ephemeral planning documents +Add `create_plan` tool for creating ephemeral planning documents diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index cfb3721d442..6bc4c1f8e52 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -39,7 +39,7 @@ export const toolNames = [ "report_bug", "condense", "delete_file", - "create_draft", + "create_plan", // kilocode_change end "update_todo_list", "run_slash_command", diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 325f1975f02..151f31dcf11 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -28,6 +28,9 @@ vi.mock("vscode", () => ({ }, workspace: { registerTextDocumentContentProvider: vi.fn(), + registerFileSystemProvider: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), getConfiguration: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue([]), }), @@ -50,6 +53,11 @@ vi.mock("vscode", () => ({ onDidCloseTextDocument: vi.fn().mockReturnValue({ dispose: vi.fn(), }), + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + }, }, languages: { registerCodeActionsProvider: vi.fn(), @@ -63,6 +71,7 @@ vi.mock("vscode", () => ({ env: { language: "en", appName: "Visual Studio Code", + version: "1.0.0", }, ExtensionMode: { Production: 1, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index a96163dd88f..48637aa6e3a 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -31,7 +31,7 @@ import { switchModeTool } from "../tools/SwitchModeTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" -import { createDraftTool } from "../tools/CreateDraftTool" // kilocode_change +import { createPlanTool } from "../tools/CreatePlanTool" // kilocode_change import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { generateImageTool } from "../tools/GenerateImageTool" @@ -467,7 +467,7 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name}]` case "condense": return `[${block.name}]` - case "create_draft": + case "create_plan": return `[${block.name} for '${block.params.title}']` // kilocode_change end case "run_slash_command": @@ -1102,8 +1102,8 @@ export async function presentAssistantMessage(cline: Task) { case "condense": await condenseTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break - case "create_draft": - await createDraftTool.handle(cline, block as ToolUse<"create_draft">, { + case "create_plan": + await createPlanTool.handle(cline, block as ToolUse<"create_plan">, { askApproval, handleError, pushToolResult, diff --git a/src/core/kilocode/wrapper.ts b/src/core/kilocode/wrapper.ts index 42e57745af6..b4746119ed9 100644 --- a/src/core/kilocode/wrapper.ts +++ b/src/core/kilocode/wrapper.ts @@ -3,7 +3,7 @@ import { JETBRAIN_PRODUCTS, KiloCodeWrapperProperties } from "../../shared/kiloc export const getKiloCodeWrapperProperties = (): KiloCodeWrapperProperties => { const appName = vscode.env.appName - const kiloCodeWrapped = appName.includes("wrapper") + const kiloCodeWrapped = appName?.includes("wrapper") ?? false let kiloCodeWrapper = null let kiloCodeWrapperTitle = null let kiloCodeWrapperCode = null @@ -37,7 +37,7 @@ export const getEditorNameHeader = () => { return ( props.kiloCodeWrapped ? [props.kiloCodeWrapperTitle, props.kiloCodeWrapperVersion] - : [vscode.env.appName, vscode.version] + : [vscode.env.appName || "VS Code", vscode.version] ) .filter(Boolean) .join(" ") diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index b1df753cce7..0aa3ab272a8 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -229,48 +229,75 @@ Delete a directory (requires approval with statistics): ``` -## create_draft -Description: Create a temporary planning document for structuring your analysis, architectural decisions, implementation plans, or other working documents. These documents are ideal for drafting content that you want the user to review before committing to the codebase. - -Use create_draft when: -- Creating planning documents, implementation plans, or architectural diagrams -- Drafting content for user review and feedback -- Organizing your analysis and reasoning process -- The user wants to review and refine plans before implementation +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session The created document will: -- Appear as a normal editor tab that can be edited by both you and the user -- Be accessible via read_file and write_to_file tools -- Be saved to disk (outside the repository) and persist across sessions - -When to use create_draft vs write_to_file: -- Use create_draft for planning documents, drafts, and temporary working documents -- Use write_to_file when the user wants content saved directly to the workspace (e.g., explicit file creation requests, writing docs that should be committed to the repo) +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document Usage: - + document-title Your document content here - + Example: Creating a planning document - + implementation-plan # Implementation Plan -...more +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C - + Example: Creating a quick note - + quick-notes Remember to: @@ -278,7 +305,7 @@ Remember to: 2. Test edge cases 3. Update tests - + ## ask_followup_question Description: Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index 2ff7f0ece06..43fd7fadb69 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -231,28 +231,60 @@ Example: -## create_draft -Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session The created document will: - Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be accessible via read_file and write_to_file tools using the returned plan:// path - Be automatically discarded when the editor session ends (not saved to disk) Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document Usage: - + document-title Your document content here - + Example: Creating a planning document - + implementation-plan # Implementation Plan @@ -264,10 +296,10 @@ Example: Creating a planning document ## Step 2 - Task C - + Example: Creating a quick note - + quick-notes Remember to: @@ -275,7 +307,7 @@ Remember to: 2. Test edge cases 3. Update tests - + ## update_todo_list diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap index 122fcb3d6e3..40392c3a0d1 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap @@ -228,28 +228,60 @@ Delete a directory (requires approval with statistics): ``` -## create_draft -Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session The created document will: - Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be accessible via read_file and write_to_file tools using the returned plan:// path - Be automatically discarded when the editor session ends (not saved to disk) Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document Usage: - + document-title Your document content here - + Example: Creating a planning document - + implementation-plan # Implementation Plan @@ -261,10 +293,10 @@ Example: Creating a planning document ## Step 2 - Task C - + Example: Creating a quick note - + quick-notes Remember to: @@ -272,7 +304,7 @@ Remember to: 2. Test edge cases 3. Update tests - + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap index f7736ca7b40..2606c1b6b9a 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap @@ -229,28 +229,60 @@ Delete a directory (requires approval with statistics): ``` -## create_draft -Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session The created document will: - Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be accessible via read_file and write_to_file tools using the returned plan:// path - Be automatically discarded when the editor session ends (not saved to disk) Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document Usage: - + document-title Your document content here - + Example: Creating a planning document - + implementation-plan # Implementation Plan @@ -262,10 +294,10 @@ Example: Creating a planning document ## Step 2 - Task C - + Example: Creating a quick note - + quick-notes Remember to: @@ -273,7 +305,7 @@ Remember to: 2. Test edge cases 3. Update tests - + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap index 6d1a9bbb698..58f5ae69872 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap @@ -234,28 +234,60 @@ Delete a directory (requires approval with statistics): ``` -## create_draft -Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session The created document will: - Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path +- Be accessible via read_file and write_to_file tools using the returned plan:// path - Be automatically discarded when the editor session ends (not saved to disk) Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document Usage: - + document-title Your document content here - + Example: Creating a planning document - + implementation-plan # Implementation Plan @@ -267,10 +299,10 @@ Example: Creating a planning document ## Step 2 - Task C - + Example: Creating a quick note - + quick-notes Remember to: @@ -278,7 +310,7 @@ Remember to: 2. Test edge cases 3. Update tests - + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index 4d172ba382c..6a11c431e4a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap index 1f4e7683e79..1823c12530f 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## browser_action Description: Request to interact with a Puppeteer-controlled browser. Every action, except `close`, will be responded to with a screenshot of the browser's current state, along with any new console logs. You may only perform one browser action per message, and wait for the user's response including a screenshot and logs to determine the next action. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap index 4d172ba382c..6a11c431e4a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 911df2f88eb..0d906dcbcab 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -317,6 +317,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap index 4d172ba382c..6a11c431e4a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap index 4d172ba382c..6a11c431e4a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index cc7113024ac..2606c1b6b9a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index 4d172ba382c..6a11c431e4a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -229,6 +229,84 @@ Delete a directory (requires approval with statistics): ``` +## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + + + ## execute_command Description: Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Prefer relative commands and paths that avoid location sensitivity for terminal consistency, e.g: `touch ./testdata/example.file`, `dir ./examples/model1/data/yaml`, or `go test ./cmd/front --config ./cmd/front/config.yml`. If directed by the user, you may open a terminal in a different directory by using the `cwd` parameter. Parameters: diff --git a/src/core/prompts/tools/create-draft.ts b/src/core/prompts/tools/create-draft.ts deleted file mode 100644 index ee219138ae8..00000000000 --- a/src/core/prompts/tools/create-draft.ts +++ /dev/null @@ -1,50 +0,0 @@ -// kilocode_change start: Add create_draft tool description function -import { ToolArgs } from "./types" - -export function getCreateDraftDescription(args: ToolArgs): string { - return `## create_draft -Description: Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. - -The created document will: -- Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path -- Be automatically discarded when the editor session ends (not saved to disk) - -Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename (automatically adds .md extension if not present) -- content: (required) The initial content of the draft document - -Usage: - -document-title - -Your document content here - - - -Example: Creating a planning document - -implementation-plan - -# Implementation Plan - -## Step 1 -- Task A -- Task B - -## Step 2 -- Task C - - - -Example: Creating a quick note - -quick-notes - -Remember to: -1. Check API documentation -2. Test edge cases -3. Update tests - -` -} diff --git a/src/core/prompts/tools/create-plan.ts b/src/core/prompts/tools/create-plan.ts new file mode 100644 index 00000000000..0ea1b62d37b --- /dev/null +++ b/src/core/prompts/tools/create-plan.ts @@ -0,0 +1,82 @@ +// kilocode_change start: Add create_plan tool description function +import { ToolArgs } from "./types" + +export function getCreatePlanDescription(args: ToolArgs): string { + return `## create_plan +Description: Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Usage: + +document-title + +Your document content here + + + +Example: Creating a planning document + +implementation-plan + +# Implementation Plan + +## Step 1 +- Task A +- Task B + +## Step 2 +- Task C + + + +Example: Creating a quick note + +quick-notes + +Remember to: +1. Check API documentation +2. Test edge cases +3. Update tests + +` +} diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index bf0584ea304..4b970b26b60 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -342,14 +342,14 @@ export function filterNativeToolsForMode( allowedToolNames.delete("access_mcp_resource") } - // kilocode_change start - create_draft tool exclusion - // Conditionally exclude create_draft if running in CLI or JetBrains mode - // (drafts require VS Code editor UI which CLI and JetBrains don't have) + // kilocode_change start - create_plan tool exclusion + // Conditionally exclude create_plan if running in CLI or JetBrains mode + // (plans require VS Code editor UI which CLI and JetBrains don't have) const { kiloCodeWrapperCode, kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() if (kiloCodeWrapperJetbrains || kiloCodeWrapperCode === "cli") { - allowedToolNames.delete("create_draft") + allowedToolNames.delete("create_plan") } - // kilocode_change end - create_draft tool exclusion + // kilocode_change end - create_plan tool exclusion // Filter native tools based on allowed tool names and apply alias renames const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index ff3ba273d1e..1cc80451783 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -27,13 +27,13 @@ import { getGenerateImageDescription } from "./generate-image" import { getDeleteFileDescription } from "./delete-file" // kilocode_change import { CodeIndexManager } from "../../../services/code-index/manager" -// kilocode_change start: Morph fast apply + create_draft import +// kilocode_change start: Morph fast apply + create_plan import import { isFastApplyAvailable } from "../../tools/kilocode/editFileTool" import { getEditFileDescription } from "./edit-file" import { type ClineProviderState } from "../../webview/ClineProvider" import { ManagedIndexer } from "../../../services/code-index/managed/ManagedIndexer" -import { getCreateDraftDescription } from "./create-draft" -// kilocode_change end: Morph fast apply + create_draft import +import { getCreatePlanDescription } from "./create-plan" +// kilocode_change end: Morph fast apply + create_plan import // Map of tool names to their description functions const toolDescriptionMap: Record string | undefined> = { @@ -60,7 +60,7 @@ const toolDescriptionMap: Record string | undefined> new_task: (args) => getNewTaskDescription(args), edit_file: () => getEditFileDescription(), // kilocode_change: Morph fast apply delete_file: (args) => getDeleteFileDescription(args), // kilocode_change - create_draft: (args) => getCreateDraftDescription(args), // kilocode_change + create_plan: (args) => getCreatePlanDescription(args), // kilocode_change apply_diff: (args) => args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", update_todo_list: (args) => getUpdateTodoListDescription(args), diff --git a/src/core/prompts/tools/native-tools/create_draft.ts b/src/core/prompts/tools/native-tools/create_draft.ts deleted file mode 100644 index 9a07ada660b..00000000000 --- a/src/core/prompts/tools/native-tools/create_draft.ts +++ /dev/null @@ -1,47 +0,0 @@ -// kilocode_change - new file: Native tool definition for create_draft -import type OpenAI from "openai" - -const CREATE_DRAFT_DESCRIPTION = `Create a temporary, in-memory planning document that appears as an editor tab but is not saved to disk. These documents use a special draft:// URI scheme and are discarded when the editor session ends. This enables creating planning documents, implementation plans, and other ephemeral working documents for structured thinking. - -The created document will: -- Appear as a normal editor tab that can be edited by the user -- Be accessible via read_file and write_to_file tools using the returned draft:// path -- Be automatically discarded when the editor session ends (not saved to disk) - -Parameters: -- title: (required) The title/name of the draft document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present) -- content: (required) The initial content of the draft document - -Example: Creating a planning document -{ "title": "implementation-plan", "content": "# Implementation Plan\n\n## Step 1\n- Task A\n- Task B\n\n## Step 2\n- Task C" } - -Example: Creating a quick note -{ "title": "quick-notes", "content": "Remember to:\n1. Check API documentation\n2. Test edge cases\n3. Update tests" }` - -const TITLE_PARAMETER_DESCRIPTION = `The title/name of the draft document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present)` - -const CONTENT_PARAMETER_DESCRIPTION = `The initial content of the draft document` - -export default { - type: "function", - function: { - name: "create_draft", - description: CREATE_DRAFT_DESCRIPTION, - strict: true, - parameters: { - type: "object", - properties: { - title: { - type: "string", - description: TITLE_PARAMETER_DESCRIPTION, - }, - content: { - type: "string", - description: CONTENT_PARAMETER_DESCRIPTION, - }, - }, - required: ["title", "content"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/create_plan.ts b/src/core/prompts/tools/native-tools/create_plan.ts new file mode 100644 index 00000000000..cf8dec0f301 --- /dev/null +++ b/src/core/prompts/tools/native-tools/create_plan.ts @@ -0,0 +1,79 @@ +// kilocode_change - new file: Native tool definition for create_plan +import type OpenAI from "openai" + +const CREATE_PLAN_DESCRIPTION = `Create an ephemeral planning document for brainstorming, strategy, and structured thinking. The document appears as an editor tab and is fully accessible during this session, but will be automatically discarded when the editor session ends (not saved to disk). + +DEFAULT RULE: Use create_plan for ANY planning task unless the user EXPLICITLY requests disk persistence. The word "plan", "planning", "plan document", or any planning-related request should default to create_plan. + +WHEN TO USE create_plan (DEFAULT for planning): +- ANY request involving "plan", "planning", "plan document", "create a plan", "plan something" +- Outlining strategies, organizing thoughts, brainstorming +- Requests to "plan it first" or "create a planning document" +- Iterating on ideas before committing to implementation +- Collaborative planning sessions +- When user asks about planning documents or wants to check for them +- IMPORTANT: Even if you think the user "might want persistence" - use create_plan unless they explicitly say so + +WHEN NOT TO USE create_plan (use write_to_file instead): +- User EXPLICITLY says "save to disk", "persist", "save permanently", or "write to file" +- User EXPLICITLY provides a specific file path like "/plans/my-plan.md" or "save it to plans/" +- User EXPLICITLY mentions they want the document to "survive sessions" or "persist across sessions" +- User uses the word "write" or "save" instead of "plan" or "create" +- DO NOT infer persistence from context - only use write_to_file if user explicitly requests it + +COMMON MISTAKES TO AVOID: +- ❌ DON'T use write_to_file just because you think "planning documents should persist" - that's an inference, not an explicit request +- ❌ DON'T use write_to_file because "real-world projects need persistent files" - use create_plan unless explicitly told otherwise +- ❌ DON'T use write_to_file because the user "might want to revisit it later" - use create_plan unless they explicitly say so +- ✅ DO use create_plan for any planning task unless the user explicitly requests persistence + +KEY POINTS: +- Ephemeral plans are FULLY FUNCTIONAL during the session - they're not "temporary" in the sense of being less useful +- Ephemeral plans appear as editor tabs and are FULLY EDITABLE during this session +- Can be read and modified using read_file and write_to_file with the returned plan:// path +- Even ephemeral plans can be referenced by Code mode during this session - they don't need to be saved to disk to be useful +- The key distinction is EXPLICIT REQUEST for persistence vs DEFAULT ephemeral behavior +- "Ephemeral" means session-only, NOT "unusable" or "temporary" - it's a fully functional document during the session + +The created document will: +- Appear as a normal editor tab that can be edited by the user +- Be accessible via read_file and write_to_file tools using the returned plan:// path +- Be automatically discarded when the editor session ends (not saved to disk) + +Parameters: +- title: (required) The title/name of the plan document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present) +- content: (required) The initial content of the plan document + +Example: Creating a planning document +{ "title": "implementation-plan", "content": "# Implementation Plan\n\n## Step 1\n- Task A\n- Task B\n\n## Step 2\n- Task C" } + +Example: Creating a quick note +{ "title": "quick-notes", "content": "Remember to:\n1. Check API documentation\n2. Test edge cases\n3. Update tests" }` + +const TITLE_PARAMETER_DESCRIPTION = `The title/name of the plan document. Will be used as the filename with a unique ID suffix (automatically adds .plan.md extension if not present)` + +const CONTENT_PARAMETER_DESCRIPTION = `The initial content of the plan document` + +export default { + type: "function", + function: { + name: "create_plan", + description: CREATE_PLAN_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + title: { + type: "string", + description: TITLE_PARAMETER_DESCRIPTION, + }, + content: { + type: "string", + description: CONTENT_PARAMETER_DESCRIPTION, + }, + }, + required: ["title", "content"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index f04b8c54fb3..159d9c24121 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -6,7 +6,7 @@ import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" import codebaseSearch from "./codebase_search" -import createDraft from "./create_draft" // kilocode_change +import createPlan from "./create_plan" // kilocode_change import executeCommand from "./execute_command" import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" @@ -42,7 +42,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat // condenseTool, // newRuleTool, // reportBugTool, - createDraft, // kilocode_change + createPlan, // kilocode_change // kilocode_change end accessMcpResource, apply_diff, diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 4974742dda2..cbfb606e111 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -17,7 +17,7 @@ import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change -import { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } from "../../services/planning" // kilocode_change +import { isPlanPath, normalizePlanPath, planPathToFilename, PLAN_SCHEME_NAME } from "../../services/planning" // kilocode_change interface ApplyDiffParams { path: string @@ -57,10 +57,10 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // kilocode_change start: Handle draft documents - const isDraft = isDraftPath(relPath) - const canonicalPath = isDraft ? normalizeDraftPath(relPath) : relPath - const filename = isDraft ? draftPathToFilename(relPath) : undefined + // kilocode_change start: Handle plan documents + const isPlan = isPlanPath(relPath) + const canonicalPath = isPlan ? normalizePlanPath(relPath) : relPath + const filename = isPlan ? planPathToFilename(relPath) : undefined // kilocode_change end @@ -72,26 +72,26 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // kilocode_change start: Handle draft documents + // kilocode_change start: Handle plan documents let originalContent: string let absolutePath: string let fileExists = false // kilocode_change - if (isDraft) { - // For draft documents, read using the draft file system - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) - console.log("📝 [ApplyDiffTool] reading draft document:", uri.toString()) + if (isPlan) { + // For plan documents, read using the plan file system + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + console.log("📝 [ApplyDiffTool] reading plan document:", uri.toString()) try { const contentBytes = await vscode.workspace.fs.readFile(uri) originalContent = new TextDecoder().decode(contentBytes) - console.log("📝 [ApplyDiffTool] draft read successful, size:", originalContent.length) - fileExists = true // kilocode_change: draft exists since we just read it + console.log("📝 [ApplyDiffTool] plan read successful, size:", originalContent.length) + fileExists = true // kilocode_change: plan exists since we just read it } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [ApplyDiffTool] ERROR reading draft:", errorMsg) + console.error("📝 [ApplyDiffTool] ERROR reading plan:", errorMsg) task.consecutiveMistakeCount++ task.recordToolError("apply_diff") - const formattedError = `Draft document does not exist at path: ${canonicalPath}\n\n\nThe draft document could not be found. Please verify the draft exists and try again.\n` + const formattedError = `Plan document does not exist at path: ${canonicalPath}\n\n\nThe plan document could not be found. Please verify the plan exists and try again.\n` await task.say("error", formattedError) task.didToolFailInCurrentTurn = true pushToolResult(formattedError) @@ -191,11 +191,11 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diff: diffContent, } - // kilocode_change start: Handle draft documents separately - if (isDraft) { - // For draft documents, apply the diff and write directly using vscode.workspace.fs - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) - console.log("📝 [ApplyDiffTool] applying diff to draft document:", uri.toString()) + // kilocode_change start: Handle plan documents separately + if (isPlan) { + // For plan documents, apply the diff and write directly using vscode.workspace.fs + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + console.log("📝 [ApplyDiffTool] applying diff to plan document:", uri.toString()) // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( @@ -209,7 +209,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (!diffResult.success) { task.consecutiveMistakeCount++ - let formattedError = `Unable to apply diff to draft document: ${canonicalPath}\n\n\n${diffResult.error || "Unknown error"}\n` + let formattedError = `Unable to apply diff to plan document: ${canonicalPath}\n\n\n${diffResult.error || "Unknown error"}\n` await task.say("error", formattedError) task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) @@ -218,17 +218,17 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { task.consecutiveMistakeCount = 0 - // Write the updated content back to the draft + // Write the updated content back to the plan const contentBytes = new TextEncoder().encode(diffResult.content) await vscode.workspace.fs.writeFile(uri, contentBytes) - console.log("📝 [ApplyDiffTool] draft updated successfully") + console.log("📝 [ApplyDiffTool] plan updated successfully") // Track file edit operation await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) task.didEditFile = true // Generate a simple message for the tool result - const message = `Applied diff to draft document: ${canonicalPath}` + const message = `Applied diff to plan document: ${canonicalPath}` pushToolResult(message) task.processQueuedMessages() return diff --git a/src/core/tools/CreateDraftTool.ts b/src/core/tools/CreatePlanTool.ts similarity index 53% rename from src/core/tools/CreateDraftTool.ts rename to src/core/tools/CreatePlanTool.ts index 69216e239a5..ec62b17a10e 100644 --- a/src/core/tools/CreateDraftTool.ts +++ b/src/core/tools/CreatePlanTool.ts @@ -1,50 +1,50 @@ // kilocode_change - new file import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" -import { getDraftFileSystem } from "../../services/planning" +import { getPlanFileSystem } from "../../services/planning" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -interface CreateDraftParams { +interface CreatePlanParams { title: string content: string } -export class CreateDraftTool extends BaseTool<"create_draft"> { - readonly name = "create_draft" as const +export class CreatePlanTool extends BaseTool<"create_plan"> { + readonly name = "create_plan" as const - parseLegacy(params: Partial>): CreateDraftParams { + parseLegacy(params: Partial>): CreatePlanParams { return { title: params.title || "", content: params.content || "", } } - async execute(params: CreateDraftParams, task: Task, callbacks: ToolCallbacks): Promise { + async execute(params: CreatePlanParams, task: Task, callbacks: ToolCallbacks): Promise { const { title, content } = params const { handleError, pushToolResult } = callbacks - console.log("📝 [CreateDraftTool] execute title=", title, "contentLength=", content.length) - // Validate required parameters if (!title) { task.consecutiveMistakeCount++ - task.recordToolError("create_draft") - pushToolResult(await task.sayAndCreateMissingParamError("create_draft", "title")) + task.recordToolError("create_plan") + pushToolResult(await task.sayAndCreateMissingParamError("create_plan", "title")) return } if (content === undefined || content === null) { task.consecutiveMistakeCount++ - task.recordToolError("create_draft") - pushToolResult(await task.sayAndCreateMissingParamError("create_draft", "content")) + task.recordToolError("create_plan") + pushToolResult(await task.sayAndCreateMissingParamError("create_plan", "content")) return } + console.log("📝 [CreatePlanTool] execute title=", title, "contentLength=", content.length) + // Validate title length if (title.length > 255) { task.consecutiveMistakeCount++ - task.recordToolError("create_draft") + task.recordToolError("create_plan") pushToolResult(formatResponse.toolError("Title must be 255 characters or less")) return } @@ -52,7 +52,7 @@ export class CreateDraftTool extends BaseTool<"create_draft"> { // Validate content is not too large (prevent memory issues) if (content.length > 1000000) { task.consecutiveMistakeCount++ - task.recordToolError("create_draft") + task.recordToolError("create_plan") pushToolResult(formatResponse.toolError("Content must be 1MB or less")) return } @@ -60,32 +60,32 @@ export class CreateDraftTool extends BaseTool<"create_draft"> { task.consecutiveMistakeCount = 0 try { - const fs = getDraftFileSystem() - console.log("📝 [CreateDraftTool] calling fs.createAndOpen") - const draftPath = await fs.createAndOpen(title, content) - console.log("📝 [CreateDraftTool] fs.createAndOpen returned draftPath=", draftPath) + const fs = getPlanFileSystem() + console.log("📝 [CreatePlanTool] calling fs.createAndOpen") + const planPath = await fs.createAndOpen(title, content) + console.log("📝 [CreatePlanTool] fs.createAndOpen returned planPath=", planPath) // Return success message with instructions - const message = `Created draft document "${title}". The document has been opened in an editor tab.\n\nYou can now:\n- Read it using: read_file with path "${draftPath}"\n- Update it using: write_to_file with path "${draftPath}"\n\nThe document will be discarded when the editor session ends.` + const message = `Created plan document "${title}". The document has been opened in an editor tab.\n\nYou can now:\n- Read it using: read_file with path "${planPath}"\n- Update it using: write_to_file with path "${planPath}"\n\nThe document will be discarded when the editor session ends.` pushToolResult(formatResponse.toolResult(message)) - task.recordToolUsage("create_draft") + task.recordToolUsage("create_plan") } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.log("📝 [CreateDraftTool] error=", errorMessage) - pushToolResult(formatResponse.toolError(`Failed to create draft document: ${errorMessage}`)) - await handleError("creating draft document", error as Error) + console.log("📝 [CreatePlanTool] error=", errorMessage) + pushToolResult(formatResponse.toolError(`Failed to create plan document: ${errorMessage}`)) + await handleError("creating plan document", error as Error) } } - override async handlePartial(task: Task, block: ToolUse<"create_draft">): Promise { - // Show "Creating draft..." message during streaming + override async handlePartial(task: Task, block: ToolUse<"create_plan">): Promise { + // Show "Creating plan..." message during streaming const title = this.removeClosingTag("title", block.params.title, block.partial) const content = this.removeClosingTag("content", block.params.content, block.partial) if (title) { const partialMessage = JSON.stringify({ - tool: "createDraft", + tool: "createPlan", title: title, content: content, }) @@ -95,4 +95,4 @@ export class CreateDraftTool extends BaseTool<"create_draft"> { } } -export const createDraftTool = new CreateDraftTool() +export const createPlanTool = new CreatePlanTool() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8b2dc16d84c..4c23e3a222c 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -25,7 +25,7 @@ import { processImageFile, ImageMemoryTracker, } from "./helpers/imageHelpers" -import { isDraftPath, readDraftDocument } from "./helpers/draftDocumentHelpers" // kilocode_change +import { isPlanPath, readPlanDocument } from "./helpers/planDocumentHelpers" // kilocode_change import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget" import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -177,9 +177,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } if (fileResult.status === "pending") { - // kilocode_change start: Skip approval for draft documents - // Skip approval for draft documents (auto-approved) - if (isDraftPath(relPath)) { + // kilocode_change start: Skip approval for plan documents + // Skip approval for plan documents (auto-approved) + if (isPlanPath(relPath)) { updateFileResult(relPath, { status: "approved" }) continue } @@ -344,9 +344,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { const relPath = fileResult.path - // kilocode_change start: Handle draft document reading - if (isDraftPath(relPath)) { - const result = await readDraftDocument(relPath, task) + // kilocode_change start: Handle plan document reading + if (isPlanPath(relPath)) { + const result = await readPlanDocument(relPath, task) if (result.status === "error") { updateFileResult(relPath, { status: "error", @@ -355,7 +355,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { nativeContent: result.nativeContent, }) if (result.error) { - await handleError(`reading draft document ${relPath}`, new Error(result.error)) + await handleError(`reading plan document ${relPath}`, new Error(result.error)) } } else { updateFileResult(relPath, { diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 4740797ac64..3ad4889c722 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -15,7 +15,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { normalizeLineEndings_kilocode } from "./kilocode/normalizeLineEndings" -import { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } from "./helpers/draftDocumentHelpers" // kilocode_change +import { isPlanPath, normalizePlanPath, planPathToFilename, PLAN_SCHEME_NAME } from "./helpers/planDocumentHelpers" // kilocode_change interface SearchReplaceOperation { search: string @@ -88,22 +88,22 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } } - // kilocode_change start: Handle draft documents - if (isDraftPath(relPath)) { - const canonicalPath = normalizeDraftPath(relPath) - const filename = draftPathToFilename(relPath) + // kilocode_change start: Handle plan documents + if (isPlanPath(relPath)) { + const canonicalPath = normalizePlanPath(relPath) + const filename = planPathToFilename(relPath) - // Read draft document + // Read plan document let fileContent: string try { - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) const contentBytes = await vscode.workspace.fs.readFile(uri) fileContent = new TextDecoder().decode(contentBytes) } catch (error) { task.consecutiveMistakeCount++ task.recordToolError("search_and_replace") const errorMsg = error instanceof Error ? error.message : "Unknown error" - const errorMessage = `Failed to read draft document '${relPath}': ${errorMsg}` + const errorMessage = `Failed to read plan document '${relPath}': ${errorMsg}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) return @@ -156,9 +156,9 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { return } - // Write draft document + // Write plan document try { - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) const contentBytes = new TextEncoder().encode(newContent) await vscode.workspace.fs.writeFile(uri, contentBytes) @@ -170,15 +170,15 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { if (errors.length > 0) { resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` } - resultMessage += `Updated draft document "${canonicalPath}"` + resultMessage += `Updated plan document "${canonicalPath}"` pushToolResult(formatResponse.toolResult(resultMessage)) task.recordToolUsage("search_and_replace") return } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error" - await handleError("writing draft document", new Error(errorMsg)) - pushToolResult(formatResponse.toolError(`Failed to write draft document: ${errorMsg}`)) + await handleError("writing plan document", new Error(errorMsg)) + pushToolResult(formatResponse.toolError(`Failed to write plan document: ${errorMsg}`)) return } } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index d906f632f7b..6b5399c611e 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -16,7 +16,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -import { isDraftPath, writeDraftDocument } from "./helpers/draftDocumentHelpers" // kilocode_change +import { isPlanPath, writePlanDocument, convertToPlanPathIfNeeded } from "./helpers/planDocumentHelpers" // kilocode_change import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface WriteToFileParams { @@ -55,17 +55,41 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - // Check if this is a draft document - if (isDraftPath(relPath)) { - const result = await writeDraftDocument(relPath, newContent, task) + // kilocode_change start: Auto-redirect /plans/ paths to plan:// schema + // Check if this path should be converted to a plan document (e.g., /plans/...) + const convertedPlanPath = convertToPlanPathIfNeeded(relPath) + if (convertedPlanPath) { + console.log( + `📝 [WriteToFileTool] Redirecting /plans/ path "${relPath}" to plan document "${convertedPlanPath}"`, + ) + const result = await writePlanDocument(convertedPlanPath, newContent, task) + if ("error" in result) { + pushToolResult(formatResponse.toolError(result.error)) + await handleError("writing plan document", new Error(result.error)) + return + } + + task.didEditFile = true + pushToolResult( + formatResponse.toolResult( + `Redirected /plans/ path to ephemeral plan document "${result.canonicalPath}". The document will be discarded when the editor session ends.`, + ), + ) + return + } + // kilocode_change end + + // Check if this is a plan document (already using plan:// schema) + if (isPlanPath(relPath)) { + const result = await writePlanDocument(relPath, newContent, task) if ("error" in result) { pushToolResult(formatResponse.toolError(result.error)) - await handleError("writing draft document", new Error(result.error)) + await handleError("writing plan document", new Error(result.error)) return } task.didEditFile = true - pushToolResult(formatResponse.toolResult(`Updated draft document "${result.canonicalPath}"`)) + pushToolResult(formatResponse.toolResult(`Updated plan document "${result.canonicalPath}"`)) return } @@ -253,9 +277,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - // Skip partial handling for draft documents - they don't use the diff view provider - // and we don't want to create filesystem directories for draft:// paths - if (isDraftPath(relPath)) { + // Skip partial handling for plan documents - they don't use the diff view provider + // and we don't want to create filesystem directories for plan:// paths or /plans/ paths + // (isPlanPath now includes both plan:// URIs and absolute /plans/ paths) + if (isPlanPath(relPath)) { return } diff --git a/src/core/tools/__tests__/createDraftTool.spec.ts b/src/core/tools/__tests__/createPlanTool.spec.ts similarity index 71% rename from src/core/tools/__tests__/createDraftTool.spec.ts rename to src/core/tools/__tests__/createPlanTool.spec.ts index ab2f5cf4d2d..50e79326038 100644 --- a/src/core/tools/__tests__/createDraftTool.spec.ts +++ b/src/core/tools/__tests__/createPlanTool.spec.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { createDraftTool } from "../CreateDraftTool" +import { createPlanTool } from "../CreatePlanTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" -import { getDraftFileSystem } from "../../../services/planning" +import { getPlanFileSystem } from "../../../services/planning" // Mock the planning service vi.mock("../../../services/planning", () => ({ - getDraftFileSystem: vi.fn(() => ({ + getPlanFileSystem: vi.fn(() => ({ createAndOpen: vi.fn(), })), })) @@ -14,7 +14,7 @@ vi.mock("../../../services/planning", () => ({ // Mock vscode for integration tests - using vi.doMock to avoid hoisting issues vi.doMock("vscode", () => ({ Uri: { - parse: vi.fn((str) => ({ scheme: "draft", path: str.replace("draft://", "/") })), + parse: vi.fn((str) => ({ scheme: "plan", path: str.replace("plan://", "/") })), }, workspace: { fs: { @@ -59,7 +59,7 @@ vi.doMock("vscode", () => ({ })), })) -describe("createDraftTool", () => { +describe("createPlanTool", () => { let mockTask: Task let mockPushToolResult: any let mockHandleError: any @@ -85,29 +85,29 @@ describe("createDraftTool", () => { describe("parameter validation", () => { it("should error when title is missing", async () => { - await createDraftTool.execute({ title: "", content: "test content" }, mockTask, { + await createPlanTool.execute({ title: "", content: "test content" }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("create_draft") + expect(mockTask.recordToolError).toHaveBeenCalledWith("create_plan") expect(mockPushToolResult).toHaveBeenCalled() }) it("should error when content is missing (undefined)", async () => { - await createDraftTool.execute({ title: "test-title", content: undefined as any }, mockTask, { + await createPlanTool.execute({ title: "test-title", content: undefined as any }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("create_draft") + expect(mockTask.recordToolError).toHaveBeenCalledWith("create_plan") expect(mockPushToolResult).toHaveBeenCalled() }) it("should error when content is null", async () => { - await createDraftTool.execute({ title: "test-title", content: null as any }, mockTask, { + await createPlanTool.execute({ title: "test-title", content: null as any }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) @@ -119,7 +119,7 @@ describe("createDraftTool", () => { it("should error when title exceeds 255 characters", async () => { const longTitle = "a".repeat(256) - await createDraftTool.execute({ title: longTitle, content: "test content" }, mockTask, { + await createPlanTool.execute({ title: longTitle, content: "test content" }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) @@ -133,7 +133,7 @@ describe("createDraftTool", () => { it("should error when content exceeds 1MB", async () => { const largeContent = "a".repeat(1000001) - await createDraftTool.execute({ title: "test-title", content: largeContent }, mockTask, { + await createPlanTool.execute({ title: "test-title", content: largeContent }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) @@ -145,7 +145,7 @@ describe("createDraftTool", () => { describe("parseLegacy", () => { it("should parse legacy XML parameters", () => { - const result = createDraftTool.parseLegacy({ + const result = createPlanTool.parseLegacy({ title: "legacy-title", content: "legacy content", }) @@ -157,7 +157,7 @@ describe("createDraftTool", () => { }) it("should return empty strings for missing parameters", () => { - const result = createDraftTool.parseLegacy({}) + const result = createPlanTool.parseLegacy({}) expect(result).toEqual({ title: "", @@ -168,18 +168,18 @@ describe("createDraftTool", () => { describe("tool name", () => { it("should have correct name", () => { - expect(createDraftTool.name).toBe("create_draft") + expect(createPlanTool.name).toBe("create_plan") }) }) - describe("draft workflow integration", () => { - it("should create draft with unique content", async () => { + describe("plan workflow integration", () => { + it("should create plan with unique content", async () => { const mockFs = { - createAndOpen: vi.fn().mockResolvedValue("draft://my-test.md"), + createAndOpen: vi.fn().mockResolvedValue("plan://my-test.md"), } - vi.mocked(getDraftFileSystem).mockReturnValue(mockFs as any) + vi.mocked(getPlanFileSystem).mockReturnValue(mockFs as any) - await createDraftTool.execute( + await createPlanTool.execute( { title: "my-test", content: "# My Test Document\n\nTest content." }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError } as any, @@ -189,31 +189,31 @@ describe("createDraftTool", () => { expect(mockPushToolResult).toHaveBeenCalled() }) - it("should handle multiple draft creations with different content", async () => { + it("should handle multiple plan creations with different content", async () => { const mockFs = { createAndOpen: vi .fn() - .mockResolvedValueOnce("draft://first.md") - .mockResolvedValueOnce("draft://second.md"), + .mockResolvedValueOnce("plan://first.md") + .mockResolvedValueOnce("plan://second.md"), } - vi.mocked(getDraftFileSystem).mockReturnValue(mockFs as any) + vi.mocked(getPlanFileSystem).mockReturnValue(mockFs as any) - // Create first draft - await createDraftTool.execute({ title: "first", content: "# First Draft\n\nContent one." }, mockTask, { + // Create first plan + await createPlanTool.execute({ title: "first", content: "# First Plan\n\nContent one." }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) - // Create second draft - await createDraftTool.execute({ title: "second", content: "# Second Draft\n\nContent two." }, mockTask, { + // Create second plan + await createPlanTool.execute({ title: "second", content: "# Second Plan\n\nContent two." }, mockTask, { pushToolResult: mockPushToolResult, handleError: mockHandleError, } as any) - // Verify both drafts were created with their respective content + // Verify both plans were created with their respective content expect(mockFs.createAndOpen).toHaveBeenCalledTimes(2) - expect(mockFs.createAndOpen).toHaveBeenCalledWith("first", "# First Draft\n\nContent one.") - expect(mockFs.createAndOpen).toHaveBeenCalledWith("second", "# Second Draft\n\nContent two.") + expect(mockFs.createAndOpen).toHaveBeenCalledWith("first", "# First Plan\n\nContent one.") + expect(mockFs.createAndOpen).toHaveBeenCalledWith("second", "# Second Plan\n\nContent two.") }) }) }) diff --git a/src/core/tools/helpers/draftDocumentHelpers.ts b/src/core/tools/helpers/draftDocumentHelpers.ts deleted file mode 100644 index 723f8ab1070..00000000000 --- a/src/core/tools/helpers/draftDocumentHelpers.ts +++ /dev/null @@ -1,142 +0,0 @@ -// kilocode_change - new file -import * as vscode from "vscode" -import { addLineNumbers } from "../../../integrations/misc/extract-text" -import { - isDraftPath, - normalizeDraftPath, - draftPathToFilename, - DRAFT_SCHEME_NAME, - getDraftFileSystem, -} from "../../../services/planning" -import type { Task } from "../../task/Task" -import type { RecordSource } from "../../context-tracking/FileContextTrackerTypes" - -export { isDraftPath, normalizeDraftPath, draftPathToFilename, DRAFT_SCHEME_NAME } - -/** - * Read a draft document and return formatted result. - * Shared helper for both ReadFileTool and simpleReadFileTool. - */ -export async function readDraftDocument( - relPath: string, - task: Task, -): Promise<{ - status: "approved" | "error" - xmlContent?: string - nativeContent?: string - error?: string -}> { - console.log("📝 [readDraftDocument] START - relPath:", relPath) - const canonicalPath = normalizeDraftPath(relPath) - const filename = draftPathToFilename(relPath) - console.log("📝 [readDraftDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) - - try { - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) - console.log("📝 [readDraftDocument] constructed URI:", uri.toString()) - console.log("📝 [readDraftDocument] calling vscode.workspace.fs.readFile...") - const contentBytes = await vscode.workspace.fs.readFile(uri) - console.log("📝 [readDraftDocument] vscode.workspace.fs.readFile SUCCESS, size:", contentBytes.length) - const content = new TextDecoder().decode(contentBytes) - const numberedContent = addLineNumbers(content) - const totalLines = content.split("\n").length - console.log("📝 [readDraftDocument] decoded content, totalLines:", totalLines) - - await task.fileContextTracker.trackFileContext(canonicalPath, "read_tool" as RecordSource) - - const lineRangeAttr = ` lines="1-${totalLines}"` - const xmlInfo = totalLines > 0 ? `\n${numberedContent}\n` : `` - const nativeInfo = - totalLines > 0 - ? `File: ${canonicalPath}\nLines: 1-${totalLines}\n\n${numberedContent}` - : `File: ${canonicalPath}\n(empty file)` - - console.log("📝 [readDraftDocument] SUCCESS - returning content") - return { - status: "approved", - xmlContent: `${canonicalPath}\n${xmlInfo}`, - nativeContent: nativeInfo, - } - } catch (error) { - const isNotFoundError = error instanceof Error && error.message.includes("FileNotFound") - const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [readDraftDocument] ERROR:", errorMsg) - if (error instanceof Error) { - console.error("📝 [readDraftDocument] ERROR stack:", error.stack) - } - - if (isNotFoundError) { - const draftName = filename.replace(/\.plan\.md$/, "").replace(/\.md$/, "") - console.log("📝 [readDraftDocument] returning FileNotFound error for draft:", draftName) - return { - status: "error", - error: `Draft document "${draftName}" does not exist. Use the create_draft tool to create it.`, - xmlContent: `${canonicalPath}Draft document "${draftName}" does not exist. Use the create_draft tool with a title and content to create a new draft document.`, - nativeContent: `File: ${canonicalPath}\nError: Draft document "${draftName}" does not exist. Use the create_draft tool with a title and content to create a new draft document.`, - } - } - - console.log("📝 [readDraftDocument] returning generic error") - return { - status: "error", - error: `Error reading draft document: ${errorMsg}`, - xmlContent: `${canonicalPath}Error reading draft document: ${errorMsg}`, - nativeContent: `File: ${canonicalPath}\nError: Error reading draft document: ${errorMsg}`, - } - } -} - -/** - * Write content to a draft document. - * Helper for WriteToFileTool. - */ -export async function writeDraftDocument( - relPath: string, - content: string, - task: Task, -): Promise<{ canonicalPath: string } | { error: string }> { - console.log("📝 [writeDraftDocument] START - relPath:", relPath, "contentLength:", content.length) - const canonicalPath = normalizeDraftPath(relPath) - const filename = draftPathToFilename(relPath) - console.log("📝 [writeDraftDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) - - try { - // Check if draft exists before writing - const draftFs = getDraftFileSystem() - console.log("📝 [writeDraftDocument] checking if draft exists...") - const wasNew = !(await draftFs.draftExists(canonicalPath)) - console.log("📝 [writeDraftDocument] draft exists check - wasNew:", wasNew) - - const uri = vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) - console.log("📝 [writeDraftDocument] constructed URI:", uri.toString()) - const contentBytes = new TextEncoder().encode(content) - console.log("📝 [writeDraftDocument] calling vscode.workspace.fs.writeFile...") - await vscode.workspace.fs.writeFile(uri, contentBytes) - console.log("📝 [writeDraftDocument] vscode.workspace.fs.writeFile SUCCESS") - - // If this is a new draft document, open it in VS Code - if (wasNew) { - console.log("📝 [writeDraftDocument] opening new draft document in editor") - await vscode.window.showTextDocument(uri, { preview: false }) - } - - await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) - console.log("📝 [writeDraftDocument] SUCCESS - returning canonicalPath:", canonicalPath) - return { canonicalPath } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [writeDraftDocument] ERROR:", errorMsg) - if (error instanceof Error) { - console.error("📝 [writeDraftDocument] ERROR stack:", error.stack) - } - return { error: `Error writing draft document: ${errorMsg}` } - } -} - -/** - * Check if a path is a draft document path. - * Convenience function that re-exports from draftPaths. - */ -export function isDraftDocumentPath(path: string): boolean { - return isDraftPath(path) -} diff --git a/src/core/tools/helpers/planDocumentHelpers.ts b/src/core/tools/helpers/planDocumentHelpers.ts new file mode 100644 index 00000000000..a2890beb14c --- /dev/null +++ b/src/core/tools/helpers/planDocumentHelpers.ts @@ -0,0 +1,161 @@ +// kilocode_change - new file +import * as vscode from "vscode" +import { addLineNumbers } from "../../../integrations/misc/extract-text" +import { + isPlanPath, + normalizePlanPath, + planPathToFilename, + filenameToPlanPath, + PLAN_SCHEME_NAME, + getPlanFileSystem, +} from "../../../services/planning" +import type { Task } from "../../task/Task" +import type { RecordSource } from "../../context-tracking/FileContextTrackerTypes" + +export { isPlanPath, normalizePlanPath, planPathToFilename, PLAN_SCHEME_NAME } + +/** + * Read a plan document and return formatted result. + * Shared helper for both ReadFileTool and simpleReadFileTool. + */ +export async function readPlanDocument( + relPath: string, + task: Task, +): Promise<{ + status: "approved" | "error" + xmlContent?: string + nativeContent?: string + error?: string +}> { + console.log("📝 [readPlanDocument] START - relPath:", relPath) + const canonicalPath = normalizePlanPath(relPath) + const filename = planPathToFilename(relPath) + console.log("📝 [readPlanDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) + + try { + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + console.log("📝 [readPlanDocument] constructed URI:", uri.toString()) + console.log("📝 [readPlanDocument] calling vscode.workspace.fs.readFile...") + const contentBytes = await vscode.workspace.fs.readFile(uri) + console.log("📝 [readPlanDocument] vscode.workspace.fs.readFile SUCCESS, size:", contentBytes.length) + const content = new TextDecoder().decode(contentBytes) + const numberedContent = addLineNumbers(content) + const totalLines = content.split("\n").length + console.log("📝 [readPlanDocument] decoded content, totalLines:", totalLines) + + await task.fileContextTracker.trackFileContext(canonicalPath, "read_tool" as RecordSource) + + const lineRangeAttr = ` lines="1-${totalLines}"` + const xmlInfo = totalLines > 0 ? `\n${numberedContent}\n` : `` + const nativeInfo = + totalLines > 0 + ? `File: ${canonicalPath}\nLines: 1-${totalLines}\n\n${numberedContent}` + : `File: ${canonicalPath}\n(empty file)` + + console.log("📝 [readPlanDocument] SUCCESS - returning content") + return { + status: "approved", + xmlContent: `${canonicalPath}\n${xmlInfo}`, + nativeContent: nativeInfo, + } + } catch (error) { + const isNotFoundError = error instanceof Error && error.message.includes("FileNotFound") + const errorMsg = error instanceof Error ? error.message : "Unknown error" + console.error("📝 [readPlanDocument] ERROR:", errorMsg) + if (error instanceof Error) { + console.error("📝 [readPlanDocument] ERROR stack:", error.stack) + } + + if (isNotFoundError) { + const planName = filename.replace(/\.plan\.md$/, "").replace(/\.md$/, "") + console.log("📝 [readPlanDocument] returning FileNotFound error for plan:", planName) + return { + status: "error", + error: `Plan document "${planName}" does not exist. Use the create_plan tool to create it.`, + xmlContent: `${canonicalPath}Plan document "${planName}" does not exist. Use the create_plan tool with a title and content to create a new plan document.`, + nativeContent: `File: ${canonicalPath}\nError: Plan document "${planName}" does not exist. Use the create_plan tool with a title and content to create a new plan document.`, + } + } + + console.log("📝 [readPlanDocument] returning generic error") + return { + status: "error", + error: `Error reading plan document: ${errorMsg}`, + xmlContent: `${canonicalPath}Error reading plan document: ${errorMsg}`, + nativeContent: `File: ${canonicalPath}\nError: Error reading plan document: ${errorMsg}`, + } + } +} + +/** + * Write content to a plan document. + * Helper for WriteToFileTool. + */ +export async function writePlanDocument( + relPath: string, + content: string, + task: Task, +): Promise<{ canonicalPath: string } | { error: string }> { + console.log("📝 [writePlanDocument] START - relPath:", relPath, "contentLength:", content.length) + const canonicalPath = normalizePlanPath(relPath) + const filename = planPathToFilename(relPath) + console.log("📝 [writePlanDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) + + try { + // Check if plan exists before writing + const planFs = getPlanFileSystem() + console.log("📝 [writePlanDocument] checking if plan exists...") + const wasNew = !(await planFs.planExists(canonicalPath)) + console.log("📝 [writePlanDocument] plan exists check - wasNew:", wasNew) + + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + console.log("📝 [writePlanDocument] constructed URI:", uri.toString()) + const contentBytes = new TextEncoder().encode(content) + console.log("📝 [writePlanDocument] calling vscode.workspace.fs.writeFile...") + await vscode.workspace.fs.writeFile(uri, contentBytes) + console.log("📝 [writePlanDocument] vscode.workspace.fs.writeFile SUCCESS") + + // If this is a new plan document, open it in VS Code + if (wasNew) { + console.log("📝 [writePlanDocument] opening new plan document in editor") + await vscode.window.showTextDocument(uri, { preview: false }) + } + + await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) + console.log("📝 [writePlanDocument] SUCCESS - returning canonicalPath:", canonicalPath) + return { canonicalPath } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + console.error("📝 [writePlanDocument] ERROR:", errorMsg) + if (error instanceof Error) { + console.error("📝 [writePlanDocument] ERROR stack:", error.stack) + } + return { error: `Error writing plan document: ${errorMsg}` } + } +} + +/** + * Check if a path is a plan document path. + * Convenience function that re-exports from planPaths. + */ +export function isPlanDocumentPath(path: string): boolean { + return isPlanPath(path) +} + +/** + * If the file path should be a plan document (absolute /plans/ path), + * convert it to a plan:// URI. Returns undefined if not a /plans/ path. + * Note: This only converts /plans/ paths, not already-converted plan:// paths. + * + * @param filePath - The file path to check and potentially convert + * @returns The plan:// URI if the path should be converted, or undefined + */ +export function convertToPlanPathIfNeeded(filePath: string): string | undefined { + // Only convert /plans/ paths (not already plan:// paths) + if (filePath.startsWith("/plans/")) { + // Extract filename from /plans/filename.md -> filename.md + const filename = filePath.replace(/^\/plans\//, "").replace(/^\//, "") + return filenameToPlanPath(filename) + } + return undefined +} diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts index e9a2760b4d8..b56d026fa41 100644 --- a/src/core/tools/simpleReadFileTool.ts +++ b/src/core/tools/simpleReadFileTool.ts @@ -14,7 +14,7 @@ import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import { isDraftPath, readDraftDocument } from "./helpers/draftDocumentHelpers" +import { isPlanPath, readPlanDocument } from "./helpers/planDocumentHelpers" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, @@ -77,9 +77,9 @@ export async function simpleReadFileTool( const fullPath = path.resolve(cline.cwd, relPath) try { - // Check if this is a draft document - if (isDraftPath(relPath)) { - const result = await readDraftDocument(relPath, cline) + // Check if this is a plan document + if (isPlanPath(relPath)) { + const result = await readPlanDocument(relPath, cline) const effectiveProtocol: ToolProtocol = toolProtocol || TOOL_PROTOCOL.XML if (result.status === "error") { // Return error based on protocol diff --git a/src/extension.ts b/src/extension.ts index 37d02eb08c8..c7815f9dc86 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,7 +48,7 @@ import { getKiloCodeWrapperProperties } from "./core/kilocode/wrapper" // kiloco import { checkAnthropicApiKeyConflict } from "./utils/anthropicApiKeyWarning" // kilocode_change import { SettingsSyncService } from "./services/settings-sync/SettingsSyncService" // kilocode_change import { ManagedIndexer } from "./services/code-index/managed/ManagedIndexer" // kilocode_change -import { registerDraftFileSystem } from "./services/planning" // kilocode_change +import { registerPlanFileSystem } from "./services/planning" // kilocode_change import { flushModels, getModels, initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { kilo_initializeSessionManager } from "./shared/kilocode/cli-sessions/extension/session-manager-utils" // kilocode_change @@ -467,7 +467,7 @@ export async function activate(context: vscode.ExtensionContext) { // Only foward logs in Jetbrains registerMainThreadForwardingLogger(context) } else { - registerDraftFileSystem(context) + registerPlanFileSystem(context) } // Don't register the ghost provider for the CLI if (kiloCodeWrapperCode !== "cli") { @@ -512,7 +512,7 @@ export async function activate(context: vscode.ExtensionContext) { reloadTimeout = setTimeout(() => { console.log(`♻️ Reloading host after debounce delay...`) - vscode.commands.executeCommand("workbench.action.reloadWindow") + // vscode.commands.executeCommand("workbench.action.reloadWindow") }, DEBOUNCE_DELAY) } diff --git a/src/services/planning/DraftFileSystemProvider.ts b/src/services/planning/PlanFileSystemProvider.ts similarity index 59% rename from src/services/planning/DraftFileSystemProvider.ts rename to src/services/planning/PlanFileSystemProvider.ts index b6eb218add7..6374d90a32e 100644 --- a/src/services/planning/DraftFileSystemProvider.ts +++ b/src/services/planning/PlanFileSystemProvider.ts @@ -3,13 +3,13 @@ import * as vscode from "vscode" import * as path from "path" import * as os from "os" import * as fs from "fs/promises" -import { DRAFT_SCHEME_NAME, filenameToDraftPath, draftPathToFilename } from "./draftPaths" +import { PLAN_SCHEME_NAME, filenameToPlanPath, planPathToFilename } from "./planPaths" /** - * Generate a unique draft ID similar to Cursor's plan IDs. + * Generate a unique plan ID similar to Cursor's plan IDs. * Uses 7 random hex characters for collision avoidance. */ -function generateDraftId(): string { +function generatePlanId(): string { const hexChars = "0123456789abcdef" let result = "" for (let i = 0; i < 7; i++) { @@ -19,10 +19,10 @@ function generateDraftId(): string { } /** - * File system provider for draft:// documents. + * File system provider for plan:// documents. * Stores documents on disk at ~/.kilocode/plans/ and makes them available as editor tabs. */ -export class DraftFileSystemProvider implements vscode.FileSystemProvider { +export class PlanFileSystemProvider implements vscode.FileSystemProvider { private readonly _emitter = new vscode.EventEmitter() private readonly _plansDir: string @@ -30,18 +30,18 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { constructor() { this._plansDir = path.join(os.homedir(), ".kilocode", "plans") - console.log("📝 [DraftFSP] constructor - plansDir:", this._plansDir) + console.log("📝 [PlanFSP] constructor - plansDir:", this._plansDir) fs.mkdir(this._plansDir, { recursive: true }) .then(() => { - console.log("📝 [DraftFSP] constructor - plans directory created/verified") + console.log("📝 [PlanFSP] constructor - plans directory created/verified") }) .catch((error) => { - console.error("📝 [DraftFSP] constructor - Failed to create plans directory:", error) + console.error("📝 [PlanFSP] constructor - Failed to create plans directory:", error) }) } /** - * Get the real filesystem path for a draft filename. + * Get the real filesystem path for a plan filename. * @param filename - The filename (e.g., "my-document.md") * @returns The absolute path to the file on disk */ @@ -50,7 +50,7 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } /** - * Convert a draft URI path to filename (internal storage key). + * Convert a plan URI path to filename (internal storage key). * Handles all URI variants consistently by stripping scheme and leading slashes. * @param uri - The VS Code URI * @returns The filename without leading slash @@ -62,29 +62,29 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } /** - * Convert a filename to VS Code URI for the draft scheme. - * Always produces the canonical form: draft:/filename (single slash). + * Convert a filename to VS Code URI for the plan scheme. + * Always produces the canonical form: plan:/filename (single slash). * @param filename - The filename (without leading slash) - * @returns The VS Code URI with draft:// scheme + * @returns The VS Code URI with plan:// scheme */ private filenameToUri(filename: string): vscode.Uri { - // Always use / prefix for the URI path - produces canonical draft:/filename - return vscode.Uri.parse(`${DRAFT_SCHEME_NAME}:/${filename}`) + // Always use / prefix for the URI path - produces canonical plan:/filename + return vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) } watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { - // No-op: we don't support watching draft documents + // No-op: we don't support watching plan documents return new vscode.Disposable(() => {}) } async stat(uri: vscode.Uri): Promise { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) - console.log("📝 [DraftFSP] stat - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) + console.log("📝 [PlanFSP] stat - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) try { const stats = await fs.stat(realPath) - console.log("📝 [DraftFSP] stat - SUCCESS, size:", stats.size) + console.log("📝 [PlanFSP] stat - SUCCESS, size:", stats.size) return { type: vscode.FileType.File, ctime: stats.birthtimeMs, @@ -93,7 +93,7 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } } catch (error) { const err = error as NodeJS.ErrnoException - console.log("📝 [DraftFSP] stat - ERROR:", err.code, err.message) + console.log("📝 [PlanFSP] stat - ERROR:", err.code, err.message) if (err.code === "ENOENT") { throw vscode.FileSystemError.FileNotFound(uri) } @@ -102,27 +102,27 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { - // Draft documents don't support directories + // Plan documents don't support directories throw vscode.FileSystemError.FileNotFound() } createDirectory(_uri: vscode.Uri): void { - // Draft documents don't support directories + // Plan documents don't support directories throw vscode.FileSystemError.NoPermissions() } async readFile(uri: vscode.Uri): Promise { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) - console.log("📝 [DraftFSP] readFile - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) + console.log("📝 [PlanFSP] readFile - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) try { const content = await fs.readFile(realPath) - console.log("📝 [DraftFSP] readFile - SUCCESS, size:", content.length) + console.log("📝 [PlanFSP] readFile - SUCCESS, size:", content.length) return content } catch (error) { const err = error as NodeJS.ErrnoException - console.log("📝 [DraftFSP] readFile - ERROR:", err.code, err.message) + console.log("📝 [PlanFSP] readFile - ERROR:", err.code, err.message) if (err.code === "ENOENT") { throw vscode.FileSystemError.FileNotFound(uri) } @@ -138,7 +138,7 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) console.log( - "📝 [DraftFSP] writeFile - START - uri:", + "📝 [PlanFSP] writeFile - START - uri:", uri.toString(), "filename:", filename, @@ -152,28 +152,28 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { let wasNew = false try { await fs.stat(realPath) - console.log("📝 [DraftFSP] writeFile - file exists, will update") + console.log("📝 [PlanFSP] writeFile - file exists, will update") } catch { wasNew = true - console.log("📝 [DraftFSP] writeFile - file does not exist, will create") + console.log("📝 [PlanFSP] writeFile - file does not exist, will create") } // Ensure plans directory exists before writing try { await fs.mkdir(this._plansDir, { recursive: true }) - console.log("📝 [DraftFSP] writeFile - plans directory verified") + console.log("📝 [PlanFSP] writeFile - plans directory verified") } catch (error) { - console.error("📝 [DraftFSP] writeFile - ERROR creating plans directory:", error) + console.error("📝 [PlanFSP] writeFile - ERROR creating plans directory:", error) throw error } // Write to disk try { await fs.writeFile(realPath, content) - console.log("📝 [DraftFSP] writeFile - SUCCESS writing to disk") + console.log("📝 [PlanFSP] writeFile - SUCCESS writing to disk") } catch (error) { const err = error as NodeJS.ErrnoException - console.error("📝 [DraftFSP] writeFile - ERROR writing file:", err.code, err.message, err.stack) + console.error("📝 [PlanFSP] writeFile - ERROR writing file:", err.code, err.message, err.stack) throw error } @@ -184,7 +184,7 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { uri: canonicalUri, } this._emitter.fire([event]) - console.log("📝 [DraftFSP] writeFile - fired event, type:", wasNew ? "Created" : "Changed") + console.log("📝 [PlanFSP] writeFile - fired event, type:", wasNew ? "Created" : "Changed") } async delete(uri: vscode.Uri): Promise { @@ -210,29 +210,29 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void { - // Draft documents don't support rename + // Plan documents don't support rename throw vscode.FileSystemError.NoPermissions() } /** - * Create a new draft document and open it in the editor. + * Create a new plan document and open it in the editor. * @param name - The name/title of the document (will be used as filename with unique ID suffix) * @param content - Initial content of the document - * @returns The draft:// URI path (e.g., "draft://filename_7313f09d.plan.md") + * @returns The plan:// URI path (e.g., "plan://filename_7313f09d.plan.md") */ async createAndOpen(name: string, content: string): Promise { - // Generate unique draft ID and ensure name has .plan.md extension - const draftId = generateDraftId() + // Generate unique plan ID and ensure name has .plan.md extension + const planId = generatePlanId() let baseName = name if (name.endsWith(".plan.md")) { baseName = name.slice(0, -8) // Remove ".plan.md" } - const filename = `${baseName}_${draftId}.plan.md` + const filename = `${baseName}_${planId}.plan.md` console.log( - "📝 [DraftFSP] createAndOpen - START - name:", + "📝 [PlanFSP] createAndOpen - START - name:", name, - "draftId:", - draftId, + "planId:", + planId, "filename:", filename, "contentLength:", @@ -242,28 +242,28 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { // Ensure plans directory exists try { await fs.mkdir(this._plansDir, { recursive: true }) - console.log("📝 [DraftFSP] createAndOpen - plans directory verified") + console.log("📝 [PlanFSP] createAndOpen - plans directory verified") } catch (error) { - console.error("📝 [DraftFSP] createAndOpen - ERROR creating plans directory:", error) + console.error("📝 [PlanFSP] createAndOpen - ERROR creating plans directory:", error) throw error } // Store the document on disk const contentBytes = new TextEncoder().encode(content) const realPath = this.getRealPath(filename) - console.log("📝 [DraftFSP] createAndOpen - writing to realPath:", realPath) + console.log("📝 [PlanFSP] createAndOpen - writing to realPath:", realPath) try { await fs.writeFile(realPath, contentBytes) - console.log("📝 [DraftFSP] createAndOpen - SUCCESS writing to disk") + console.log("📝 [PlanFSP] createAndOpen - SUCCESS writing to disk") } catch (error) { const err = error as NodeJS.ErrnoException - console.error("📝 [DraftFSP] createAndOpen - ERROR writing file:", err.code, err.message, err.stack) + console.error("📝 [PlanFSP] createAndOpen - ERROR writing file:", err.code, err.message, err.stack) throw error } // Create URI using consistent formatting const uri = this.filenameToUri(filename) - console.log("📝 [DraftFSP] createAndOpen - created URI:", uri.toString()) + console.log("📝 [PlanFSP] createAndOpen - created URI:", uri.toString()) // Emit file change event const event: vscode.FileChangeEvent = { @@ -271,25 +271,25 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { uri, } this._emitter.fire([event]) - console.log("📝 [DraftFSP] createAndOpen - fired Created event") + console.log("📝 [PlanFSP] createAndOpen - fired Created event") // Open document in VS Code editor await vscode.window.showTextDocument(uri, { preview: false }) - console.log("📝 [DraftFSP] createAndOpen - opened document in editor") + console.log("📝 [PlanFSP] createAndOpen - opened document in editor") - // Return the draft:// path for use in tools - const result = filenameToDraftPath(filename) - console.log("📝 [DraftFSP] createAndOpen - returning draftPath:", result) + // Return the plan:// path for use in tools + const result = filenameToPlanPath(filename) + console.log("📝 [PlanFSP] createAndOpen - returning planPath:", result) return result } /** - * Get draft content for RPC access. - * @param draftPath - The draft path (e.g., "draft://filename.md") + * Get plan content for RPC access. + * @param planPath - The plan path (e.g., "plan://filename.md") * @returns Content as Uint8Array, or undefined if not found */ - async getDraftContent(draftPath: string): Promise { - const filename = draftPathToFilename(draftPath) + async getPlanContent(planPath: string): Promise { + const filename = planPathToFilename(planPath) const realPath = this.getRealPath(filename) try { @@ -301,12 +301,12 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } /** - * Set draft content from RPC (user edits from JetBrains). - * @param draftPath - The draft path + * Set plan content from RPC (user edits from JetBrains). + * @param planPath - The plan path * @param content - New content */ - async setDraftContent(draftPath: string, content: Uint8Array): Promise { - const filename = draftPathToFilename(draftPath) + async setPlanContent(planPath: string, content: Uint8Array): Promise { + const filename = planPathToFilename(planPath) const realPath = this.getRealPath(filename) // Check if file exists to determine if this is a create or update @@ -333,32 +333,32 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } /** - * Check if a draft exists. - * @param draftPath - The draft path + * Check if a plan exists. + * @param planPath - The plan path * @returns true if exists */ - async draftExists(draftPath: string): Promise { - const filename = draftPathToFilename(draftPath) + async planExists(planPath: string): Promise { + const filename = planPathToFilename(planPath) const realPath = this.getRealPath(filename) - console.log("📝 [DraftFSP] draftExists - draftPath:", draftPath, "filename:", filename, "realPath:", realPath) + console.log("📝 [PlanFSP] planExists - planPath:", planPath, "filename:", filename, "realPath:", realPath) try { await fs.stat(realPath) - console.log("📝 [DraftFSP] draftExists - file exists") + console.log("📝 [PlanFSP] planExists - file exists") return true } catch (error) { const err = error as NodeJS.ErrnoException - console.log("📝 [DraftFSP] draftExists - file does not exist, error:", err.code) + console.log("📝 [PlanFSP] planExists - file does not exist, error:", err.code) return false } } /** - * Delete a draft. - * @param draftPath - The draft path + * Delete a plan. + * @param planPath - The plan path */ - async deleteDraft(draftPath: string): Promise { - const filename = draftPathToFilename(draftPath) + async deletePlan(planPath: string): Promise { + const filename = planPathToFilename(planPath) const realPath = this.getRealPath(filename) try { @@ -371,14 +371,14 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } /** - * List all draft paths. - * @returns Array of draft:// paths + * List all plan paths. + * @returns Array of plan:// paths */ - async listDrafts(): Promise { + async listPlans(): Promise { try { await fs.mkdir(this._plansDir, { recursive: true }) const files = await fs.readdir(this._plansDir) - return files.filter((file) => file.endsWith(".plan.md")).map((filename) => filenameToDraftPath(filename)) + return files.filter((file) => file.endsWith(".plan.md")).map((filename) => filenameToPlanPath(filename)) } catch { return [] } @@ -386,30 +386,30 @@ export class DraftFileSystemProvider implements vscode.FileSystemProvider { } // Singleton instance -let draftFileSystemProvider: DraftFileSystemProvider | undefined +let planFileSystemProvider: PlanFileSystemProvider | undefined /** - * Get the singleton draft file system provider instance. + * Get the singleton plan file system provider instance. */ -export function getDraftFileSystem(): DraftFileSystemProvider { - if (!draftFileSystemProvider) { - draftFileSystemProvider = new DraftFileSystemProvider() +export function getPlanFileSystem(): PlanFileSystemProvider { + if (!planFileSystemProvider) { + planFileSystemProvider = new PlanFileSystemProvider() } - return draftFileSystemProvider + return planFileSystemProvider } /** - * Register the draft file system provider with VS Code. + * Register the plan file system provider with VS Code. * @param context - VS Code extension context */ -export function registerDraftFileSystem(context: vscode.ExtensionContext): void { - const provider = getDraftFileSystem() +export function registerPlanFileSystem(context: vscode.ExtensionContext): void { + const provider = getPlanFileSystem() context.subscriptions.push( - vscode.workspace.registerFileSystemProvider(DRAFT_SCHEME_NAME, provider, { + vscode.workspace.registerFileSystemProvider(PLAN_SCHEME_NAME, provider, { isCaseSensitive: true, }), ) - // Note: We no longer delete drafts when tabs close - they persist on disk + // Note: We no longer delete plans when tabs close - they persist on disk // Users can manually delete them if needed } diff --git a/src/services/planning/__tests__/draftPaths.spec.ts b/src/services/planning/__tests__/draftPaths.spec.ts deleted file mode 100644 index 2d2af7a82b2..00000000000 --- a/src/services/planning/__tests__/draftPaths.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - DRAFT_PROTOCOL, - DRAFT_SCHEME_NAME, - isDraftPath, - draftPathToFilename, - filenameToDraftPath, - normalizeDraftPath, -} from "../draftPaths" - -describe("draftPaths", () => { - describe("DRAFT_PROTOCOL", () => { - it("should equal 'draft://'", () => { - expect(DRAFT_PROTOCOL).toBe("draft://") - }) - }) - - describe("DRAFT_SCHEME_NAME", () => { - it("should equal 'draft'", () => { - expect(DRAFT_SCHEME_NAME).toBe("draft") - }) - }) - - describe("isDraftPath", () => { - it("should return true for draft:// paths (canonical format)", () => { - expect(isDraftPath("draft://test.md")).toBe(true) - expect(isDraftPath("draft://my-document")).toBe(true) - }) - - it("should return true for draft:/// paths (AI triple-slash format)", () => { - expect(isDraftPath("draft:///test.md")).toBe(true) - expect(isDraftPath("draft:///path/to/file.md")).toBe(true) - }) - - it("should return true for draft:/ paths (VSCode normalized format)", () => { - expect(isDraftPath("draft:/test.md")).toBe(true) - expect(isDraftPath("draft:/implementation-plan.md")).toBe(true) - expect(isDraftPath("draft:/path/to/file.md")).toBe(true) - }) - - it("should return false for non-draft paths", () => { - expect(isDraftPath("/path/to/file.md")).toBe(false) - expect(isDraftPath("file://path/to/file.md")).toBe(false) - expect(isDraftPath("test.md")).toBe(false) - }) - }) - - describe("normalizeDraftPath", () => { - it("should normalize draft:// paths (already canonical)", () => { - expect(normalizeDraftPath("draft://test.md")).toBe("draft://test.md") - expect(normalizeDraftPath("draft://path/to/file.md")).toBe("draft://path/to/file.md") - }) - - it("should normalize draft:/// paths (AI triple-slash format)", () => { - expect(normalizeDraftPath("draft:///test.md")).toBe("draft://test.md") - expect(normalizeDraftPath("draft:///implementation-plan.md")).toBe("draft://implementation-plan.md") - }) - - it("should normalize draft:/ paths (VSCode normalized format)", () => { - expect(normalizeDraftPath("draft:/test.md")).toBe("draft://test.md") - expect(normalizeDraftPath("draft:/implementation-plan.md")).toBe("draft://implementation-plan.md") - }) - - it("should throw error for non-draft paths", () => { - expect(() => normalizeDraftPath("test.md")).toThrow("Invalid draft path: test.md") - expect(() => normalizeDraftPath("/path/to/file.md")).toThrow() - }) - }) - - describe("draftPathToFilename", () => { - it("should extract filename from draft:// path (canonical)", () => { - expect(draftPathToFilename("draft://test.md")).toBe("test.md") - expect(draftPathToFilename("draft://my-document.md")).toBe("my-document.md") - }) - - it("should extract filename from draft:/// path (AI format)", () => { - expect(draftPathToFilename("draft:///test.md")).toBe("test.md") - expect(draftPathToFilename("draft:///path/to/file.md")).toBe("path/to/file.md") - }) - - it("should extract filename from draft:/ path (VSCode normalized)", () => { - expect(draftPathToFilename("draft:/test.md")).toBe("test.md") - expect(draftPathToFilename("draft:/implementation-plan.md")).toBe("implementation-plan.md") - expect(draftPathToFilename("draft:/path/to/file.md")).toBe("path/to/file.md") - }) - - it("should throw error for invalid draft paths", () => { - expect(() => draftPathToFilename("test.md")).toThrow("Invalid draft path: test.md") - expect(() => draftPathToFilename("/path/to/file.md")).toThrow() - }) - }) - - describe("filenameToDraftPath", () => { - it("should convert filename to canonical draft:// path", () => { - expect(filenameToDraftPath("test.md")).toBe("draft://test.md") - expect(filenameToDraftPath("my-document")).toBe("draft://my-document") - }) - - it("should strip leading slashes from filename", () => { - expect(filenameToDraftPath("/test.md")).toBe("draft://test.md") - expect(filenameToDraftPath("//test.md")).toBe("draft://test.md") - expect(filenameToDraftPath("///test.md")).toBe("draft://test.md") - }) - - it("should handle paths with directories", () => { - expect(filenameToDraftPath("path/to/file.md")).toBe("draft://path/to/file.md") - expect(filenameToDraftPath("/path/to/file.md")).toBe("draft://path/to/file.md") - }) - }) - - describe("roundtrip conversion", () => { - it("should maintain path integrity through roundtrip", () => { - const original = "test.md" - const draftPath = filenameToDraftPath(original) - const result = draftPathToFilename(draftPath) - expect(result).toBe(original) - }) - - it("should handle complex paths through roundtrip", () => { - const original = "path/to/my-document.md" - const draftPath = filenameToDraftPath(original) - expect(draftPath).toBe("draft://path/to/my-document.md") - const result = draftPathToFilename(draftPath) - expect(result).toBe(original) - }) - - it("should normalize any variant through roundtrip", () => { - // AI gives us triple-slash - const aiPath = "draft:///implementation-plan.md" - const normalized = normalizeDraftPath(aiPath) - expect(normalized).toBe("draft://implementation-plan.md") - - // VSCode gives us single-slash - const vscodePath = "draft:/implementation-plan.md" - const normalizedVscode = normalizeDraftPath(vscodePath) - expect(normalizedVscode).toBe("draft://implementation-plan.md") - - // Both extract same filename - expect(draftPathToFilename(aiPath)).toBe("implementation-plan.md") - expect(draftPathToFilename(vscodePath)).toBe("implementation-plan.md") - }) - }) -}) diff --git a/src/services/planning/__tests__/draftFileSystemProvider.spec.ts b/src/services/planning/__tests__/planFileSystemProvider.spec.ts similarity index 55% rename from src/services/planning/__tests__/draftFileSystemProvider.spec.ts rename to src/services/planning/__tests__/planFileSystemProvider.spec.ts index 0a1c764c318..f6e2891c860 100644 --- a/src/services/planning/__tests__/draftFileSystemProvider.spec.ts +++ b/src/services/planning/__tests__/planFileSystemProvider.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" -import { DraftFileSystemProvider } from "../DraftFileSystemProvider" -import { DRAFT_SCHEME_NAME } from "../draftPaths" +import { PlanFileSystemProvider } from "../PlanFileSystemProvider" +import { PLAN_SCHEME_NAME } from "../planPaths" import * as vscode from "vscode" import * as path from "path" import * as fs from "fs/promises" @@ -20,8 +20,8 @@ import * as os from "os" vi.mock("vscode", () => ({ Uri: { parse: vi.fn((str) => ({ - scheme: "draft", - path: str.replace("draft://", "/"), + scheme: "plan", + path: str.replace("plan://", "/"), })), }, workspace: { @@ -75,18 +75,18 @@ vi.mock("vscode", () => ({ }, })) -describe("DraftFileSystemProvider", () => { - let provider: DraftFileSystemProvider +describe("PlanFileSystemProvider", () => { + let provider: PlanFileSystemProvider let tempDir: string beforeEach(async () => { vi.clearAllMocks() // Create a temporary directory for test files - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "draft-fsp-test-")) + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "plan-fsp-test-")) // Set mock homedir to return our temp directory mockHomedir = tempDir // Get a fresh instance for each test - provider = new DraftFileSystemProvider() + provider = new PlanFileSystemProvider() }) afterEach(async () => { @@ -100,31 +100,31 @@ describe("DraftFileSystemProvider", () => { }) describe("createAndOpen", () => { - it("should create a new draft document with correct content", async () => { + it("should create a new plan document with correct content", async () => { const content = "# Test Document\n\nThis is a test." const result = await provider.createAndOpen("test-doc", content) - expect(result).toMatch(/^draft:\/\/test-doc_[a-f0-9]{7}\.plan\.md$/) + expect(result).toMatch(/^plan:\/\/test-doc_[a-f0-9]{7}\.plan\.md$/) expect(vscode.window.showTextDocument).toHaveBeenCalled() }) it("should append .plan.md extension if not present", async () => { const result = await provider.createAndOpen("my-document", "# Content") - expect(result).toMatch(/^draft:\/\/my-document_[a-f0-9]{7}\.plan\.md$/) + expect(result).toMatch(/^plan:\/\/my-document_[a-f0-9]{7}\.plan\.md$/) }) it("should preserve .plan.md extension if already present", async () => { const result = await provider.createAndOpen("existing.plan.md", "# Content") - expect(result).toMatch(/^draft:\/\/existing_[a-f0-9]{7}\.plan\.md$/) + expect(result).toMatch(/^plan:\/\/existing_[a-f0-9]{7}\.plan\.md$/) }) it("should store content that can be read back", async () => { - const content = "# My Draft\n\nSome content here." - const draftPath = await provider.createAndOpen("my-draft", content) + const content = "# My Plan\n\nSome content here." + const planPath = await provider.createAndOpen("my-plan", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) const readContent = await provider.readFile(uri) const decodedContent = new TextDecoder().decode(readContent) @@ -144,9 +144,9 @@ describe("DraftFileSystemProvider", () => { describe("readFile", () => { it("should return content for existing document", async () => { const content = "# Test Content" - const draftPath = await provider.createAndOpen("existing", content) + const planPath = await provider.createAndOpen("existing", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) const readContent = await provider.readFile(uri) const decodedContent = new TextDecoder().decode(readContent) @@ -154,16 +154,16 @@ describe("DraftFileSystemProvider", () => { }) it("should throw FileNotFound for non-existent document", async () => { - const uri = vscode.Uri.parse("draft:///nonexistent.plan.md") + const uri = vscode.Uri.parse("plan:///nonexistent.plan.md") await expect(provider.readFile(uri)).rejects.toThrow() }) it("should handle path with leading slash", async () => { const content = "# Content" - const draftPath = await provider.createAndOpen("path-test", content) + const planPath = await provider.createAndOpen("path-test", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) const readContent = await provider.readFile(uri) const decodedContent = new TextDecoder().decode(readContent) @@ -174,10 +174,10 @@ describe("DraftFileSystemProvider", () => { describe("writeFile", () => { it("should update existing document content", async () => { const originalContent = "# Original" - const draftPath = await provider.createAndOpen("updatable", originalContent) + const planPath = await provider.createAndOpen("updatable", originalContent) const newContent = "# Updated Content" - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) await provider.writeFile(uri, new TextEncoder().encode(newContent), { create: false, overwrite: true, @@ -191,10 +191,10 @@ describe("DraftFileSystemProvider", () => { it("should emit Changed event when document is updated", async () => { const content = "# Original" - const draftPath = await provider.createAndOpen("change-test", content) + const planPath = await provider.createAndOpen("change-test", content) const newContent = "# Changed" - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) await provider.writeFile(uri, new TextEncoder().encode(newContent), { create: false, overwrite: true, @@ -208,9 +208,9 @@ describe("DraftFileSystemProvider", () => { describe("delete", () => { it("should remove document from storage", async () => { const content = "# To Delete" - const draftPath = await provider.createAndOpen("delete-me", content) + const planPath = await provider.createAndOpen("delete-me", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) await provider.delete(uri) // Should throw FileNotFound after deletion @@ -219,9 +219,9 @@ describe("DraftFileSystemProvider", () => { it("should emit Deleted event when document is deleted", async () => { const content = "# Test" - const draftPath = await provider.createAndOpen("emit-test", content) + const planPath = await provider.createAndOpen("emit-test", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) await provider.delete(uri) // Event should have been fired @@ -229,22 +229,22 @@ describe("DraftFileSystemProvider", () => { }) it("should throw FileNotFound for non-existent document", async () => { - const uri = vscode.Uri.parse("draft:///never-existed.plan.md") + const uri = vscode.Uri.parse("plan:///never-existed.plan.md") await expect(provider.delete(uri)).rejects.toThrow() }) }) describe("content isolation", () => { - it("should maintain separate content for each draft", async () => { - const content1 = "# Draft 1\n\nContent of first draft." - const content2 = "# Draft 2\n\nDifferent content." + it("should maintain separate content for each plan", async () => { + const content1 = "# Plan 1\n\nContent of first plan." + const content2 = "# Plan 2\n\nDifferent content." - const draftPath1 = await provider.createAndOpen("draft-1", content1) - const draftPath2 = await provider.createAndOpen("draft-2", content2) + const planPath1 = await provider.createAndOpen("plan-1", content1) + const planPath2 = await provider.createAndOpen("plan-2", content2) - const uri1 = vscode.Uri.parse(draftPath1) - const uri2 = vscode.Uri.parse(draftPath2) + const uri1 = vscode.Uri.parse(planPath1) + const uri2 = vscode.Uri.parse(planPath2) const read1 = new TextDecoder().decode(await provider.readFile(uri1)) const read2 = new TextDecoder().decode(await provider.readFile(uri2)) @@ -254,40 +254,40 @@ describe("DraftFileSystemProvider", () => { expect(read1).not.toBe(read2) }) - it("should allow updating one draft without affecting others", async () => { + it("should allow updating one plan without affecting others", async () => { const content1 = "# Original 1" const content2 = "# Original 2" - const draftPath1 = await provider.createAndOpen("first", content1) - const draftPath2 = await provider.createAndOpen("second", content2) + const planPath1 = await provider.createAndOpen("first", content1) + const planPath2 = await provider.createAndOpen("second", content2) - // Update only first draft + // Update only first plan const updatedContent = "# Updated First" - const uri1 = vscode.Uri.parse(draftPath1) + const uri1 = vscode.Uri.parse(planPath1) await provider.writeFile(uri1, new TextEncoder().encode(updatedContent), { create: false, overwrite: true, }) - // Verify first draft is updated + // Verify first plan is updated const read1 = new TextDecoder().decode(await provider.readFile(uri1)) expect(read1).toBe(updatedContent) - // Verify second draft is unchanged - const uri2 = vscode.Uri.parse(draftPath2) + // Verify second plan is unchanged + const uri2 = vscode.Uri.parse(planPath2) const read2 = new TextDecoder().decode(await provider.readFile(uri2)) expect(read2).toBe(content2) }) - it("should isolate drafts with similar names", async () => { + it("should isolate plans with similar names", async () => { const contentA = "# Document A" const contentB = "# Document B" - const draftPathA = await provider.createAndOpen("doc", contentA) - const draftPathB = await provider.createAndOpen("doc-2", contentB) + const planPathA = await provider.createAndOpen("doc", contentA) + const planPathB = await provider.createAndOpen("doc-2", contentB) - const uriA = vscode.Uri.parse(draftPathA) - const uriB = vscode.Uri.parse(draftPathB) + const uriA = vscode.Uri.parse(planPathA) + const uriB = vscode.Uri.parse(planPathB) const readA = new TextDecoder().decode(await provider.readFile(uriA)) const readB = new TextDecoder().decode(await provider.readFile(uriB)) @@ -300,9 +300,9 @@ describe("DraftFileSystemProvider", () => { describe("stat", () => { it("should return FileStat for existing document", async () => { const content = "# Test" - const draftPath = await provider.createAndOpen("stat-test", content) + const planPath = await provider.createAndOpen("stat-test", content) - const uri = vscode.Uri.parse(draftPath) + const uri = vscode.Uri.parse(planPath) const stat = await provider.stat(uri) expect(stat.type).toBe(vscode.FileType.File) @@ -312,7 +312,7 @@ describe("DraftFileSystemProvider", () => { }) it("should throw FileNotFound for non-existent document", async () => { - const uri = vscode.Uri.parse("draft:///stat-missing.plan.md") + const uri = vscode.Uri.parse("plan:///stat-missing.plan.md") await expect(provider.stat(uri)).rejects.toThrow() }) @@ -320,7 +320,7 @@ describe("DraftFileSystemProvider", () => { describe("watch", () => { it("should return a disposable", () => { - const uri = vscode.Uri.parse("draft:///test.plan.md") + const uri = vscode.Uri.parse("plan:///test.plan.md") const disposable = provider.watch(uri, { recursive: true, excludes: [] }) expect(disposable).toBeDefined() @@ -330,7 +330,7 @@ describe("DraftFileSystemProvider", () => { describe("readDirectory", () => { it("should throw FileNotFound (directories not supported)", () => { - const uri = vscode.Uri.parse("draft:///") + const uri = vscode.Uri.parse("plan:///") expect(() => provider.readDirectory(uri)).toThrow() }) @@ -338,7 +338,7 @@ describe("DraftFileSystemProvider", () => { describe("createDirectory", () => { it("should throw NoPermissions (directories not supported)", () => { - const uri = vscode.Uri.parse("draft:///new-dir") + const uri = vscode.Uri.parse("plan:///new-dir") expect(() => provider.createDirectory(uri)).toThrow() }) @@ -346,143 +346,143 @@ describe("DraftFileSystemProvider", () => { describe("rename", () => { it("should throw NoPermissions (rename not supported)", () => { - const oldUri = vscode.Uri.parse("draft:///old.plan.md") - const newUri = vscode.Uri.parse("draft:///new.plan.md") + const oldUri = vscode.Uri.parse("plan:///old.plan.md") + const newUri = vscode.Uri.parse("plan:///new.plan.md") expect(() => provider.rename(oldUri, newUri, { overwrite: true })).toThrow() }) }) - describe("getDraftContent", () => { - it("should return correct content for existing draft", async () => { + describe("getPlanContent", () => { + it("should return correct content for existing plan", async () => { const content = new TextEncoder().encode("# Test Content") - await provider.setDraftContent("draft://test-doc.plan.md", content) + await provider.setPlanContent("plan://test-doc.plan.md", content) - const result = await provider.getDraftContent("draft://test-doc.plan.md") + const result = await provider.getPlanContent("plan://test-doc.plan.md") expect(result).toBeDefined() expect(result).toEqual(content) }) - it("should return undefined for non-existent draft", async () => { - const result = await provider.getDraftContent("draft://nonexistent.plan.md") + it("should return undefined for non-existent plan", async () => { + const result = await provider.getPlanContent("plan://nonexistent.plan.md") expect(result).toBeUndefined() }) - it("should handle draft path with triple slashes", async () => { + it("should handle plan path with triple slashes", async () => { const content = new TextEncoder().encode("# Content") - await provider.setDraftContent("draft:///path-test.plan.md", content) + await provider.setPlanContent("plan:///path-test.plan.md", content) - const result = await provider.getDraftContent("draft:///path-test.plan.md") + const result = await provider.getPlanContent("plan:///path-test.plan.md") expect(result).toEqual(content) }) }) - describe("setDraftContent", () => { - it("should update existing draft content", async () => { + describe("setPlanContent", () => { + it("should update existing plan content", async () => { const originalContent = new TextEncoder().encode("# Original") const newContent = new TextEncoder().encode("# Updated") - await provider.setDraftContent("draft://updatable.plan.md", originalContent) + await provider.setPlanContent("plan://updatable.plan.md", originalContent) - await provider.setDraftContent("draft://updatable.plan.md", newContent) + await provider.setPlanContent("plan://updatable.plan.md", newContent) - const result = await provider.getDraftContent("draft://updatable.plan.md") + const result = await provider.getPlanContent("plan://updatable.plan.md") expect(result).toEqual(newContent) }) - it("should create new draft when content does not exist", async () => { + it("should create new plan when content does not exist", async () => { const content = new TextEncoder().encode("# New Content") - await provider.setDraftContent("draft://new-doc.plan.md", content) + await provider.setPlanContent("plan://new-doc.plan.md", content) - const result = await provider.getDraftContent("draft://new-doc.plan.md") + const result = await provider.getPlanContent("plan://new-doc.plan.md") expect(result).toEqual(content) }) }) - describe("draftExists", () => { - it("should return true for existing draft", async () => { + describe("planExists", () => { + it("should return true for existing plan", async () => { const content = new TextEncoder().encode("# Test") - await provider.setDraftContent("draft://existing.plan.md", content) + await provider.setPlanContent("plan://existing.plan.md", content) - const result = await provider.draftExists("draft://existing.plan.md") + const result = await provider.planExists("plan://existing.plan.md") expect(result).toBe(true) }) - it("should return false for non-existent draft", async () => { - const result = await provider.draftExists("draft://never-existed.plan.md") + it("should return false for non-existent plan", async () => { + const result = await provider.planExists("plan://never-existed.plan.md") expect(result).toBe(false) }) - it("should return false after draft is deleted", async () => { + it("should return false after plan is deleted", async () => { const content = new TextEncoder().encode("# To Delete") - await provider.setDraftContent("draft://delete-test.plan.md", content) - await provider.deleteDraft("draft://delete-test.plan.md") + await provider.setPlanContent("plan://delete-test.plan.md", content) + await provider.deletePlan("plan://delete-test.plan.md") - const result = await provider.draftExists("draft://delete-test.plan.md") + const result = await provider.planExists("plan://delete-test.plan.md") expect(result).toBe(false) }) }) - describe("deleteDraft", () => { - it("should remove draft from storage", async () => { + describe("deletePlan", () => { + it("should remove plan from storage", async () => { const content = new TextEncoder().encode("# To Delete") - await provider.setDraftContent("draft://delete-me.plan.md", content) + await provider.setPlanContent("plan://delete-me.plan.md", content) - await provider.deleteDraft("draft://delete-me.plan.md") + await provider.deletePlan("plan://delete-me.plan.md") - const result = await provider.getDraftContent("draft://delete-me.plan.md") + const result = await provider.getPlanContent("plan://delete-me.plan.md") expect(result).toBeUndefined() }) - it("should be no-op for non-existent draft", async () => { + it("should be no-op for non-existent plan", async () => { // Should not throw - await expect(provider.deleteDraft("draft://never-existed.plan.md")).resolves.not.toThrow() + await expect(provider.deletePlan("plan://never-existed.plan.md")).resolves.not.toThrow() - // Verify no drafts were added - const drafts = await provider.listDrafts() - expect(drafts).toHaveLength(0) + // Verify no plans were added + const plans = await provider.listPlans() + expect(plans).toHaveLength(0) }) }) - describe("listDrafts", () => { - it("should return all draft paths", async () => { - await provider.setDraftContent("draft://doc1.plan.md", new TextEncoder().encode("# Doc 1")) - await provider.setDraftContent("draft://doc2.plan.md", new TextEncoder().encode("# Doc 2")) - await provider.setDraftContent("draft://doc3.plan.md", new TextEncoder().encode("# Doc 3")) + describe("listPlans", () => { + it("should return all plan paths", async () => { + await provider.setPlanContent("plan://doc1.plan.md", new TextEncoder().encode("# Doc 1")) + await provider.setPlanContent("plan://doc2.plan.md", new TextEncoder().encode("# Doc 2")) + await provider.setPlanContent("plan://doc3.plan.md", new TextEncoder().encode("# Doc 3")) - const result = await provider.listDrafts() + const result = await provider.listPlans() expect(result).toHaveLength(3) - expect(result).toContain("draft://doc1.plan.md") - expect(result).toContain("draft://doc2.plan.md") - expect(result).toContain("draft://doc3.plan.md") + expect(result).toContain("plan://doc1.plan.md") + expect(result).toContain("plan://doc2.plan.md") + expect(result).toContain("plan://doc3.plan.md") }) - it("should return empty array when no drafts exist", async () => { - const result = await provider.listDrafts() + it("should return empty array when no plans exist", async () => { + const result = await provider.listPlans() expect(result).toEqual([]) }) - it("should reflect drafts created via setDraftContent", async () => { - await provider.setDraftContent("draft://new.plan.md", new TextEncoder().encode("# New")) + it("should reflect plans created via setPlanContent", async () => { + await provider.setPlanContent("plan://new.plan.md", new TextEncoder().encode("# New")) - const result = await provider.listDrafts() + const result = await provider.listPlans() - expect(result).toContain("draft://new.plan.md") + expect(result).toContain("plan://new.plan.md") }) - it("should reflect drafts deleted via deleteDraft", async () => { - await provider.setDraftContent("draft://to-remove.plan.md", new TextEncoder().encode("# Remove")) - await provider.deleteDraft("draft://to-remove.plan.md") + it("should reflect plans deleted via deletePlan", async () => { + await provider.setPlanContent("plan://to-remove.plan.md", new TextEncoder().encode("# Remove")) + await provider.deletePlan("plan://to-remove.plan.md") - const result = await provider.listDrafts() + const result = await provider.listPlans() - expect(result).not.toContain("draft://to-remove.plan.md") + expect(result).not.toContain("plan://to-remove.plan.md") }) }) }) diff --git a/src/services/planning/__tests__/planPaths.spec.ts b/src/services/planning/__tests__/planPaths.spec.ts new file mode 100644 index 00000000000..62aa91de3cd --- /dev/null +++ b/src/services/planning/__tests__/planPaths.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest" +import { + PLAN_PROTOCOL, + PLAN_SCHEME_NAME, + isPlanPath, + planPathToFilename, + filenameToPlanPath, + normalizePlanPath, +} from "../planPaths" + +describe("planPaths", () => { + describe("PLAN_PROTOCOL", () => { + it("should equal 'plan://'", () => { + expect(PLAN_PROTOCOL).toBe("plan://") + }) + }) + + describe("PLAN_SCHEME_NAME", () => { + it("should equal 'plan'", () => { + expect(PLAN_SCHEME_NAME).toBe("plan") + }) + }) + + describe("isPlanPath", () => { + it("should return true for plan:// paths (canonical format)", () => { + expect(isPlanPath("plan://test.md")).toBe(true) + expect(isPlanPath("plan://my-document")).toBe(true) + }) + + it("should return true for plan:/// paths (AI triple-slash format)", () => { + expect(isPlanPath("plan:///test.md")).toBe(true) + expect(isPlanPath("plan:///path/to/file.md")).toBe(true) + }) + + it("should return true for plan:/ paths (VSCode normalized format)", () => { + expect(isPlanPath("plan:/test.md")).toBe(true) + expect(isPlanPath("plan:/implementation-plan.md")).toBe(true) + expect(isPlanPath("plan:/path/to/file.md")).toBe(true) + }) + + it("should return false for non-plan paths", () => { + expect(isPlanPath("/path/to/file.md")).toBe(false) + expect(isPlanPath("file://path/to/file.md")).toBe(false) + expect(isPlanPath("test.md")).toBe(false) + }) + }) + + describe("normalizePlanPath", () => { + it("should normalize plan:// paths (already canonical)", () => { + expect(normalizePlanPath("plan://test.md")).toBe("plan://test.md") + expect(normalizePlanPath("plan://path/to/file.md")).toBe("plan://path/to/file.md") + }) + + it("should normalize plan:/// paths (AI triple-slash format)", () => { + expect(normalizePlanPath("plan:///test.md")).toBe("plan://test.md") + expect(normalizePlanPath("plan:///implementation-plan.md")).toBe("plan://implementation-plan.md") + }) + + it("should normalize plan:/ paths (VSCode normalized format)", () => { + expect(normalizePlanPath("plan:/test.md")).toBe("plan://test.md") + expect(normalizePlanPath("plan:/implementation-plan.md")).toBe("plan://implementation-plan.md") + }) + + it("should throw error for non-plan paths", () => { + expect(() => normalizePlanPath("test.md")).toThrow("Invalid plan path: test.md") + expect(() => normalizePlanPath("/path/to/file.md")).toThrow() + }) + }) + + describe("planPathToFilename", () => { + it("should extract filename from plan:// path (canonical)", () => { + expect(planPathToFilename("plan://test.md")).toBe("test.md") + expect(planPathToFilename("plan://my-document.md")).toBe("my-document.md") + }) + + it("should extract filename from plan:/// path (AI format)", () => { + expect(planPathToFilename("plan:///test.md")).toBe("test.md") + expect(planPathToFilename("plan:///path/to/file.md")).toBe("path/to/file.md") + }) + + it("should extract filename from plan:/ path (VSCode normalized)", () => { + expect(planPathToFilename("plan:/test.md")).toBe("test.md") + expect(planPathToFilename("plan:/implementation-plan.md")).toBe("implementation-plan.md") + expect(planPathToFilename("plan:/path/to/file.md")).toBe("path/to/file.md") + }) + + it("should throw error for invalid plan paths", () => { + expect(() => planPathToFilename("test.md")).toThrow("Invalid plan path: test.md") + expect(() => planPathToFilename("/path/to/file.md")).toThrow() + }) + }) + + describe("filenameToPlanPath", () => { + it("should convert filename to canonical plan:// path", () => { + expect(filenameToPlanPath("test.md")).toBe("plan://test.md") + expect(filenameToPlanPath("my-document")).toBe("plan://my-document") + }) + + it("should strip leading slashes from filename", () => { + expect(filenameToPlanPath("/test.md")).toBe("plan://test.md") + expect(filenameToPlanPath("//test.md")).toBe("plan://test.md") + expect(filenameToPlanPath("///test.md")).toBe("plan://test.md") + }) + + it("should handle paths with directories", () => { + expect(filenameToPlanPath("path/to/file.md")).toBe("plan://path/to/file.md") + expect(filenameToPlanPath("/path/to/file.md")).toBe("plan://path/to/file.md") + }) + }) + + describe("roundtrip conversion", () => { + it("should maintain path integrity through roundtrip", () => { + const original = "test.md" + const planPath = filenameToPlanPath(original) + const result = planPathToFilename(planPath) + expect(result).toBe(original) + }) + + it("should handle complex paths through roundtrip", () => { + const original = "path/to/my-document.md" + const planPath = filenameToPlanPath(original) + expect(planPath).toBe("plan://path/to/my-document.md") + const result = planPathToFilename(planPath) + expect(result).toBe(original) + }) + + it("should normalize any variant through roundtrip", () => { + // AI gives us triple-slash + const aiPath = "plan:///implementation-plan.md" + const normalized = normalizePlanPath(aiPath) + expect(normalized).toBe("plan://implementation-plan.md") + + // VSCode gives us single-slash + const vscodePath = "plan:/implementation-plan.md" + const normalizedVscode = normalizePlanPath(vscodePath) + expect(normalizedVscode).toBe("plan://implementation-plan.md") + + // Both extract same filename + expect(planPathToFilename(aiPath)).toBe("implementation-plan.md") + expect(planPathToFilename(vscodePath)).toBe("implementation-plan.md") + }) + }) +}) diff --git a/src/services/planning/draftPaths.ts b/src/services/planning/draftPaths.ts deleted file mode 100644 index 0c83d5649b8..00000000000 --- a/src/services/planning/draftPaths.ts +++ /dev/null @@ -1,100 +0,0 @@ -// kilocode_change - new file -/** - * Draft path utilities - single source of truth for all draft:// path handling. - * - * URI Format Background: - * - Standard URI: scheme://authority/path - * - For schemes without authority (like draft), the format is: scheme:///path or scheme:/path - * - VSCode's Uri.parse() normalizes "draft:///file.md" to "draft:/file.md" (single slash + path) - * - * Our Standard Format: - * - User-facing/canonical: "draft://filename.md" (looks familiar, clean) - * - Internal (after Uri.parse): "draft:/filename.md" (VSCode normalized) - * - * This module handles all conversions transparently. - */ - -/** - * Draft scheme name for VSCode file system registration. - * Use this when registering the FileSystemProvider. - */ -export const DRAFT_SCHEME_NAME = "draft" - -/** - * Draft protocol prefix for user-facing paths. - * This is the canonical format: "draft://filename.md" - */ -export const DRAFT_PROTOCOL = "draft://" - -/** - * Check if a path is a draft path. - * Handles all variants: draft://file.md, draft:///file.md, draft:/file.md - * - * @param path - The path to check - * @returns true if the path is a draft path - */ -export function isDraftPath(path: string): boolean { - return path.startsWith(`${DRAFT_SCHEME_NAME}:`) -} - -/** - * Normalize any draft path variant to the canonical format: "draft://filename.md" - * - * Handles: - * - "draft://file.md" -> "draft://file.md" (already canonical) - * - "draft:///file.md" -> "draft://file.md" (triple slash from AI) - * - "draft:/file.md" -> "draft://file.md" (VSCode normalized) - * - * @param draftPath - Any draft path variant - * @returns Canonical draft path: "draft://filename.md" - * @throws Error if not a valid draft path - */ -export function normalizeDraftPath(draftPath: string): string { - if (!isDraftPath(draftPath)) { - throw new Error(`Invalid draft path: ${draftPath}`) - } - - // Extract filename by removing scheme and all leading slashes - const afterScheme = draftPath.slice(`${DRAFT_SCHEME_NAME}:`.length) - const filename = afterScheme.replace(/^\/+/, "") - - // Return canonical format - return `${DRAFT_PROTOCOL}${filename}` -} - -/** - * Extract filename from a draft path. - * Handles all variants: draft://file.md, draft:///file.md, draft:/file.md - * - * @param draftPath - The draft path (any variant) - * @returns The filename without protocol or leading slashes (e.g., "filename.md") - * @throws Error if the path is not a valid draft path - */ -export function draftPathToFilename(draftPath: string): string { - if (!isDraftPath(draftPath)) { - throw new Error(`Invalid draft path: ${draftPath}`) - } - - // Remove scheme prefix - const afterScheme = draftPath.slice(`${DRAFT_SCHEME_NAME}:`.length) - // Remove any leading slashes (handles //, ///, or /) - const result = afterScheme.replace(/^\/+/, "") - console.log(`📝 [draftPaths] draftPathToFilename: "${draftPath}" -> "${result}"`) - return result -} - -/** - * Convert a filename to the canonical draft:// path. - * Always returns clean "draft://filename.md" format. - * - * @param filename - The filename (e.g., "filename.md" or "/filename.md") - * @returns The draft path in canonical format (e.g., "draft://filename.md") - */ -export function filenameToDraftPath(filename: string): string { - // Remove any leading slashes from filename - const cleanFilename = filename.replace(/^\/+/, "") - // Return canonical format: draft://filename.md - const result = `${DRAFT_PROTOCOL}${cleanFilename}` - console.log(`📝 [draftPaths] filenameToDraftPath: "${filename}" -> "${result}"`) - return result -} diff --git a/src/services/planning/index.ts b/src/services/planning/index.ts index e72facf10d2..78139507aee 100644 --- a/src/services/planning/index.ts +++ b/src/services/planning/index.ts @@ -1,10 +1,10 @@ // kilocode_change - new file -export { getDraftFileSystem, registerDraftFileSystem, DraftFileSystemProvider } from "./DraftFileSystemProvider" +export { getPlanFileSystem, registerPlanFileSystem, PlanFileSystemProvider } from "./PlanFileSystemProvider" export { - DRAFT_SCHEME_NAME, - DRAFT_PROTOCOL, - isDraftPath, - draftPathToFilename, - filenameToDraftPath, - normalizeDraftPath, -} from "./draftPaths" + PLAN_SCHEME_NAME, + PLAN_PROTOCOL, + isPlanPath, + planPathToFilename, + filenameToPlanPath, + normalizePlanPath, +} from "./planPaths" diff --git a/src/services/planning/planPaths.ts b/src/services/planning/planPaths.ts new file mode 100644 index 00000000000..abd43a053f1 --- /dev/null +++ b/src/services/planning/planPaths.ts @@ -0,0 +1,123 @@ +// kilocode_change - new file +/** + * Plan path utilities - single source of truth for all plan:// path handling. + * + * URI Format Background: + * - Standard URI: scheme://authority/path + * - For schemes without authority (like plan), the format is: scheme:///path or scheme:/path + * - VSCode's Uri.parse() normalizes "plan:///file.md" to "plan:/file.md" (single slash + path) + * + * Our Standard Format: + * - User-facing/canonical: "plan://filename.md" (looks familiar, clean) + * - Internal (after Uri.parse): "plan:/filename.md" (VSCode normalized) + * + * This module handles all conversions transparently. + */ + +/** + * Plan scheme name for VSCode file system registration. + * Use this when registering the FileSystemProvider. + */ +export const PLAN_SCHEME_NAME = "plan" + +/** + * Plan protocol prefix for user-facing paths. + * This is the canonical format: "plan://filename.md" + */ +export const PLAN_PROTOCOL = "plan://" + +/** + * Check if a path is a plan path. + * Handles all variants: plan://file.md, plan:///file.md, plan:/file.md + * Also detects absolute /plans/... paths that should be redirected to plan:// schema. + * These absolute paths would fail anyway (EROFS) since root /plans is read-only, + * so we redirect them to ephemeral plan documents. Relative paths like "plans/..." + * are NOT matched to allow users with workspace plans/ directories to work normally. + * + * @param path - The path to check + * @returns true if the path is a plan path or should be treated as one + */ +export function isPlanPath(path: string): boolean { + // Check for plan:// URI scheme (canonical plan paths) + if (path.startsWith(`${PLAN_SCHEME_NAME}:`)) { + return true + } + // Check for absolute /plans/... paths that should be redirected + // Only absolute paths (not relative "plans/...") to avoid interfering with + // users who have a plans/ directory in their workspace + return path.startsWith("/plans/") +} + +/** + * Normalize any plan path variant to the canonical format: "plan://filename.md" + * + * Handles: + * - "plan://file.md" -> "plan://file.md" (already canonical) + * - "plan:///file.md" -> "plan://file.md" (triple slash from AI) + * - "plan:/file.md" -> "plan://file.md" (VSCode normalized) + * - "/plans/file.md" -> "plan://file.md" (absolute /plans/ paths) + * + * @param planPath - Any plan path variant + * @returns Canonical plan path: "plan://filename.md" + * @throws Error if not a valid plan path + */ +export function normalizePlanPath(planPath: string): string { + if (!isPlanPath(planPath)) { + throw new Error(`Invalid plan path: ${planPath}`) + } + + // Handle /plans/ paths by converting them first + if (planPath.startsWith("/plans/")) { + const filename = planPath.replace(/^\/plans\//, "").replace(/^\//, "") + return `${PLAN_PROTOCOL}${filename}` + } + + // Extract filename by removing scheme and all leading slashes + const afterScheme = planPath.slice(`${PLAN_SCHEME_NAME}:`.length) + const filename = afterScheme.replace(/^\/+/, "") + + // Return canonical format + return `${PLAN_PROTOCOL}${filename}` +} + +/** + * Extract filename from a plan path. + * Handles all variants: plan://file.md, plan:///file.md, plan:/file.md, /plans/file.md + * + * @param planPath - The plan path (any variant) + * @returns The filename without protocol or leading slashes (e.g., "filename.md") + * @throws Error if the path is not a valid plan path + */ +export function planPathToFilename(planPath: string): string { + if (!isPlanPath(planPath)) { + throw new Error(`Invalid plan path: ${planPath}`) + } + + // Handle /plans/ paths + if (planPath.startsWith("/plans/")) { + const result = planPath.replace(/^\/plans\//, "").replace(/^\//, "") + return result + } + + // Remove scheme prefix + const afterScheme = planPath.slice(`${PLAN_SCHEME_NAME}:`.length) + // Remove any leading slashes (handles //, ///, or /) + const result = afterScheme.replace(/^\/+/, "") + console.log(`📝 [planPaths] planPathToFilename: "${planPath}" -> "${result}"`) + return result +} + +/** + * Convert a filename to the canonical plan:// path. + * Always returns clean "plan://filename.md" format. + * + * @param filename - The filename (e.g., "filename.md" or "/filename.md") + * @returns The plan path in canonical format (e.g., "plan://filename.md") + */ +export function filenameToPlanPath(filename: string): string { + // Remove any leading slashes from filename + const cleanFilename = filename.replace(/^\/+/, "") + // Return canonical format: plan://filename.md + const result = `${PLAN_PROTOCOL}${cleanFilename}` + return result +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 6a6dfd2a7bc..6c8cecf0814 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -121,7 +121,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } - create_draft: { title: string; content: string } + create_plan: { title: string; content: string } // Add more tools as they are migrated to native protocol } @@ -263,8 +263,8 @@ export interface GenerateImageToolUse extends ToolUse<"generate_image"> { params: Partial, "prompt" | "path" | "image">> } -export interface CreateDraftToolUse extends ToolUse<"create_draft"> { - name: "create_draft" +export interface CreatePlanToolUse extends ToolUse<"create_plan"> { + name: "create_plan" params: Partial, "title" | "content">> } @@ -304,7 +304,7 @@ export const TOOL_DISPLAY_NAMES: Record = { update_todo_list: "update todo list", run_slash_command: "run slash command", generate_image: "generate images", - create_draft: "create draft documents", + create_plan: "create plan documents", } as const // Define available tool groups. @@ -320,7 +320,7 @@ export const TOOL_GROUPS: Record = { "delete_file", // kilocode_change "new_rule", // kilocode_change "generate_image", - "create_draft", + "create_plan", ], customTools: ["search_and_replace", "search_replace", "apply_patch"], }, @@ -347,7 +347,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "new_task", "report_bug", "condense", // kilocode_change - "create_draft", // kilocode_change + "create_plan", // kilocode_change "update_todo_list", "run_slash_command", ] as const From c1218f1d2adadff1ee537fe8d642ff456d26d581 Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Wed, 7 Jan 2026 17:23:02 -0800 Subject: [PATCH 3/5] 'chore: WIP' --- packages/types/src/tool.ts | 2 +- src/__tests__/extension.spec.ts | 5 ++++- src/core/assistant-message/presentAssistantMessage.ts | 4 ++-- src/core/kilocode/wrapper.ts | 4 ++-- src/core/tools/__tests__/createPlanTool.spec.ts | 1 + src/core/tools/simpleReadFileTool.ts | 4 +++- .../planning/__tests__/planFileSystemProvider.spec.ts | 1 + src/services/planning/__tests__/planPaths.spec.ts | 1 + 8 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 6bc4c1f8e52..b6c1dca8f0d 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -39,7 +39,7 @@ export const toolNames = [ "report_bug", "condense", "delete_file", - "create_plan", + "create_plan", // kilocode_change // kilocode_change end "update_todo_list", "run_slash_command", diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 151f31dcf11..270dd16c3e4 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -28,6 +28,7 @@ vi.mock("vscode", () => ({ }, workspace: { registerTextDocumentContentProvider: vi.fn(), + // kilocode_change start registerFileSystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn(), }), @@ -53,11 +54,13 @@ vi.mock("vscode", () => ({ onDidCloseTextDocument: vi.fn().mockReturnValue({ dispose: vi.fn(), }), + // kilocode_change start fs: { readFile: vi.fn(), writeFile: vi.fn(), stat: vi.fn(), }, + // kilocode_change end }, languages: { registerCodeActionsProvider: vi.fn(), @@ -71,7 +74,7 @@ vi.mock("vscode", () => ({ env: { language: "en", appName: "Visual Studio Code", - version: "1.0.0", + version: "1.0.0", // kilocode_change }, ExtensionMode: { Production: 1, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 48637aa6e3a..5e296d8e102 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -468,7 +468,7 @@ export async function presentAssistantMessage(cline: Task) { case "condense": return `[${block.name}]` case "create_plan": - return `[${block.name} for '${block.params.title}']` + return `[${block.name} for '${block.params.title}']` // kilocode_change // kilocode_change end case "run_slash_command": return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` @@ -1109,7 +1109,7 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, removeClosingTag, toolProtocol, - }) + }) // kilocode_change break // kilocode_change end diff --git a/src/core/kilocode/wrapper.ts b/src/core/kilocode/wrapper.ts index b4746119ed9..12d0e26dc31 100644 --- a/src/core/kilocode/wrapper.ts +++ b/src/core/kilocode/wrapper.ts @@ -3,7 +3,7 @@ import { JETBRAIN_PRODUCTS, KiloCodeWrapperProperties } from "../../shared/kiloc export const getKiloCodeWrapperProperties = (): KiloCodeWrapperProperties => { const appName = vscode.env.appName - const kiloCodeWrapped = appName?.includes("wrapper") ?? false + const kiloCodeWrapped = appName?.includes("wrapper") ?? false // kilocode_change let kiloCodeWrapper = null let kiloCodeWrapperTitle = null let kiloCodeWrapperCode = null @@ -38,7 +38,7 @@ export const getEditorNameHeader = () => { props.kiloCodeWrapped ? [props.kiloCodeWrapperTitle, props.kiloCodeWrapperVersion] : [vscode.env.appName || "VS Code", vscode.version] - ) + ) // kilocode_change .filter(Boolean) .join(" ") } diff --git a/src/core/tools/__tests__/createPlanTool.spec.ts b/src/core/tools/__tests__/createPlanTool.spec.ts index 50e79326038..328902a3957 100644 --- a/src/core/tools/__tests__/createPlanTool.spec.ts +++ b/src/core/tools/__tests__/createPlanTool.spec.ts @@ -1,3 +1,4 @@ +// kilocode_change - new file: Tests for create_plan tool import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { createPlanTool } from "../CreatePlanTool" import { Task } from "../../task/Task" diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts index b56d026fa41..4d7fe5d0e7d 100644 --- a/src/core/tools/simpleReadFileTool.ts +++ b/src/core/tools/simpleReadFileTool.ts @@ -14,7 +14,7 @@ import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import { isPlanPath, readPlanDocument } from "./helpers/planDocumentHelpers" +import { isPlanPath, readPlanDocument } from "./helpers/planDocumentHelpers" // kilocode_change import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, @@ -78,6 +78,7 @@ export async function simpleReadFileTool( try { // Check if this is a plan document + // kilocode_change start if (isPlanPath(relPath)) { const result = await readPlanDocument(relPath, cline) const effectiveProtocol: ToolProtocol = toolProtocol || TOOL_PROTOCOL.XML @@ -98,6 +99,7 @@ export async function simpleReadFileTool( } return } + // kilocode_change end // Check RooIgnore validation const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) diff --git a/src/services/planning/__tests__/planFileSystemProvider.spec.ts b/src/services/planning/__tests__/planFileSystemProvider.spec.ts index f6e2891c860..a59d64972b7 100644 --- a/src/services/planning/__tests__/planFileSystemProvider.spec.ts +++ b/src/services/planning/__tests__/planFileSystemProvider.spec.ts @@ -1,3 +1,4 @@ +// kilocode_change - new file: Tests for PlanFileSystemProvider import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" import { PlanFileSystemProvider } from "../PlanFileSystemProvider" import { PLAN_SCHEME_NAME } from "../planPaths" diff --git a/src/services/planning/__tests__/planPaths.spec.ts b/src/services/planning/__tests__/planPaths.spec.ts index 62aa91de3cd..a446f976757 100644 --- a/src/services/planning/__tests__/planPaths.spec.ts +++ b/src/services/planning/__tests__/planPaths.spec.ts @@ -1,3 +1,4 @@ +// kilocode_change - new file: Tests for plan path utilities import { describe, it, expect } from "vitest" import { PLAN_PROTOCOL, From 18441849706b5fcc2601992a46c516ca0887eef9 Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Wed, 7 Jan 2026 17:45:33 -0800 Subject: [PATCH 4/5] refactor(planning): streamline plan document handling in tools - Replaced direct VSCode file system calls with helper functions for reading and writing plan documents. - Updated ApplyDiffTool, CreatePlanTool, and SearchAndReplaceTool to utilize the new helper methods, improving code clarity and maintainability. - Removed unnecessary logging statements to clean up the codebase. - Enhanced error handling for plan document operations, providing clearer feedback to users. This refactor enhances the overall structure of the planning tools, making them more efficient and easier to manage. --- src/core/tools/ApplyDiffTool.ts | 61 ++++--------- src/core/tools/CreatePlanTool.ts | 5 -- src/core/tools/SearchAndReplaceTool.ts | 59 +++++-------- src/core/tools/helpers/planDocumentHelpers.ts | 79 +++++++++++------ .../planning/PlanFileSystemProvider.ts | 85 ++----------------- src/services/planning/planPaths.ts | 1 - 6 files changed, 99 insertions(+), 191 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index cbfb606e111..2cdca413041 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -1,6 +1,5 @@ import path from "path" import fs from "fs/promises" -import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" @@ -17,7 +16,12 @@ import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change -import { isPlanPath, normalizePlanPath, planPathToFilename, PLAN_SCHEME_NAME } from "../../services/planning" // kilocode_change +import { + isPlanPath, + normalizePlanPath, + readPlanDocumentContent, + writePlanDocumentContent, +} from "./helpers/planDocumentHelpers" // kilocode_change interface ApplyDiffParams { path: string @@ -60,8 +64,6 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // kilocode_change start: Handle plan documents const isPlan = isPlanPath(relPath) const canonicalPath = isPlan ? normalizePlanPath(relPath) : relPath - const filename = isPlan ? planPathToFilename(relPath) : undefined - // kilocode_change end const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) @@ -75,20 +77,11 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // kilocode_change start: Handle plan documents let originalContent: string let absolutePath: string - let fileExists = false // kilocode_change + let fileExists = false if (isPlan) { - // For plan documents, read using the plan file system - const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - console.log("📝 [ApplyDiffTool] reading plan document:", uri.toString()) - try { - const contentBytes = await vscode.workspace.fs.readFile(uri) - originalContent = new TextDecoder().decode(contentBytes) - console.log("📝 [ApplyDiffTool] plan read successful, size:", originalContent.length) - fileExists = true // kilocode_change: plan exists since we just read it - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [ApplyDiffTool] ERROR reading plan:", errorMsg) + const readResult = await readPlanDocumentContent(relPath) + if ("error" in readResult) { task.consecutiveMistakeCount++ task.recordToolError("apply_diff") const formattedError = `Plan document does not exist at path: ${canonicalPath}\n\n\nThe plan document could not be found. Please verify the plan exists and try again.\n` @@ -97,7 +90,9 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { pushToolResult(formattedError) return } + originalContent = readResult.content absolutePath = canonicalPath + fileExists = true } else { // For regular files, use the existing logic absolutePath = path.resolve(task.cwd, relPath) @@ -193,42 +188,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // kilocode_change start: Handle plan documents separately if (isPlan) { - // For plan documents, apply the diff and write directly using vscode.workspace.fs - const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - console.log("📝 [ApplyDiffTool] applying diff to plan document:", uri.toString()) - - // Apply the diff to the original content - const diffResult = (await task.diffStrategy?.applyDiff( - originalContent, - diffContent, - parseInt(params.diff.match(/:start_line:(\d+)/)?.[1] ?? ""), - )) ?? { - success: false, - error: "No diff strategy available", - } - - if (!diffResult.success) { + // For plan documents, write directly without diff view + // Write the updated content back to the plan + const writeResult = await writePlanDocumentContent(relPath, diffResult.content, task) + if ("error" in writeResult) { task.consecutiveMistakeCount++ - let formattedError = `Unable to apply diff to plan document: ${canonicalPath}\n\n\n${diffResult.error || "Unknown error"}\n` + const formattedError = `Unable to write plan document: ${canonicalPath}\n\n\n${writeResult.error}\n` await task.say("error", formattedError) task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) return } - task.consecutiveMistakeCount = 0 - - // Write the updated content back to the plan - const contentBytes = new TextEncoder().encode(diffResult.content) - await vscode.workspace.fs.writeFile(uri, contentBytes) - console.log("📝 [ApplyDiffTool] plan updated successfully") - - // Track file edit operation - await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) - task.didEditFile = true - // Generate a simple message for the tool result - const message = `Applied diff to plan document: ${canonicalPath}` + const message = `Applied diff to plan document: ${writeResult.canonicalPath}` pushToolResult(message) task.processQueuedMessages() return diff --git a/src/core/tools/CreatePlanTool.ts b/src/core/tools/CreatePlanTool.ts index ec62b17a10e..2163611f8e2 100644 --- a/src/core/tools/CreatePlanTool.ts +++ b/src/core/tools/CreatePlanTool.ts @@ -39,8 +39,6 @@ export class CreatePlanTool extends BaseTool<"create_plan"> { return } - console.log("📝 [CreatePlanTool] execute title=", title, "contentLength=", content.length) - // Validate title length if (title.length > 255) { task.consecutiveMistakeCount++ @@ -61,9 +59,7 @@ export class CreatePlanTool extends BaseTool<"create_plan"> { try { const fs = getPlanFileSystem() - console.log("📝 [CreatePlanTool] calling fs.createAndOpen") const planPath = await fs.createAndOpen(title, content) - console.log("📝 [CreatePlanTool] fs.createAndOpen returned planPath=", planPath) // Return success message with instructions const message = `Created plan document "${title}". The document has been opened in an editor tab.\n\nYou can now:\n- Read it using: read_file with path "${planPath}"\n- Update it using: write_to_file with path "${planPath}"\n\nThe document will be discarded when the editor session ends.` @@ -72,7 +68,6 @@ export class CreatePlanTool extends BaseTool<"create_plan"> { task.recordToolUsage("create_plan") } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.log("📝 [CreatePlanTool] error=", errorMessage) pushToolResult(formatResponse.toolError(`Failed to create plan document: ${errorMessage}`)) await handleError("creating plan document", error as Error) } diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 3ad4889c722..4c73ff2bd7f 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -1,6 +1,5 @@ import fs from "fs/promises" import path from "path" -import * as vscode from "vscode" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -15,7 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { normalizeLineEndings_kilocode } from "./kilocode/normalizeLineEndings" -import { isPlanPath, normalizePlanPath, planPathToFilename, PLAN_SCHEME_NAME } from "./helpers/planDocumentHelpers" // kilocode_change +import { isPlanPath, readPlanDocumentContent, writePlanDocumentContent } from "./helpers/planDocumentHelpers" // kilocode_change interface SearchReplaceOperation { search: string @@ -90,25 +89,16 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { // kilocode_change start: Handle plan documents if (isPlanPath(relPath)) { - const canonicalPath = normalizePlanPath(relPath) - const filename = planPathToFilename(relPath) - - // Read plan document - let fileContent: string - try { - const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - const contentBytes = await vscode.workspace.fs.readFile(uri) - fileContent = new TextDecoder().decode(contentBytes) - } catch (error) { + const readResult = await readPlanDocumentContent(relPath) + if ("error" in readResult) { task.consecutiveMistakeCount++ task.recordToolError("search_and_replace") - const errorMsg = error instanceof Error ? error.message : "Unknown error" - const errorMessage = `Failed to read plan document '${relPath}': ${errorMsg}` - await task.say("error", errorMessage) - pushToolResult(formatResponse.toolError(errorMessage)) + await task.say("error", readResult.error) + pushToolResult(formatResponse.toolError(readResult.error)) return } + const fileContent = readResult.content const useCrLf_kilocode = fileContent.includes("\r\n") // Apply all operations sequentially @@ -157,30 +147,23 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } // Write plan document - try { - const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - const contentBytes = new TextEncoder().encode(newContent) - await vscode.workspace.fs.writeFile(uri, contentBytes) - - await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) - task.didEditFile = true - - // Add error info if some operations failed - let resultMessage = "" - if (errors.length > 0) { - resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` - } - resultMessage += `Updated plan document "${canonicalPath}"` - - pushToolResult(formatResponse.toolResult(resultMessage)) - task.recordToolUsage("search_and_replace") - return - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error" - await handleError("writing plan document", new Error(errorMsg)) - pushToolResult(formatResponse.toolError(`Failed to write plan document: ${errorMsg}`)) + const writeResult = await writePlanDocumentContent(relPath, newContent, task) + if ("error" in writeResult) { + await handleError("writing plan document", new Error(writeResult.error)) + pushToolResult(formatResponse.toolError(writeResult.error)) return } + + // Add error info if some operations failed + let resultMessage = "" + if (errors.length > 0) { + resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` + } + resultMessage += `Updated plan document "${writeResult.canonicalPath}"` + + pushToolResult(formatResponse.toolResult(resultMessage)) + task.recordToolUsage("search_and_replace") + return } // kilocode_change end diff --git a/src/core/tools/helpers/planDocumentHelpers.ts b/src/core/tools/helpers/planDocumentHelpers.ts index a2890beb14c..042cda1a71a 100644 --- a/src/core/tools/helpers/planDocumentHelpers.ts +++ b/src/core/tools/helpers/planDocumentHelpers.ts @@ -27,21 +27,15 @@ export async function readPlanDocument( nativeContent?: string error?: string }> { - console.log("📝 [readPlanDocument] START - relPath:", relPath) const canonicalPath = normalizePlanPath(relPath) const filename = planPathToFilename(relPath) - console.log("📝 [readPlanDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) try { const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - console.log("📝 [readPlanDocument] constructed URI:", uri.toString()) - console.log("📝 [readPlanDocument] calling vscode.workspace.fs.readFile...") const contentBytes = await vscode.workspace.fs.readFile(uri) - console.log("📝 [readPlanDocument] vscode.workspace.fs.readFile SUCCESS, size:", contentBytes.length) const content = new TextDecoder().decode(contentBytes) const numberedContent = addLineNumbers(content) const totalLines = content.split("\n").length - console.log("📝 [readPlanDocument] decoded content, totalLines:", totalLines) await task.fileContextTracker.trackFileContext(canonicalPath, "read_tool" as RecordSource) @@ -52,7 +46,6 @@ export async function readPlanDocument( ? `File: ${canonicalPath}\nLines: 1-${totalLines}\n\n${numberedContent}` : `File: ${canonicalPath}\n(empty file)` - console.log("📝 [readPlanDocument] SUCCESS - returning content") return { status: "approved", xmlContent: `${canonicalPath}\n${xmlInfo}`, @@ -61,14 +54,9 @@ export async function readPlanDocument( } catch (error) { const isNotFoundError = error instanceof Error && error.message.includes("FileNotFound") const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [readPlanDocument] ERROR:", errorMsg) - if (error instanceof Error) { - console.error("📝 [readPlanDocument] ERROR stack:", error.stack) - } if (isNotFoundError) { const planName = filename.replace(/\.plan\.md$/, "").replace(/\.md$/, "") - console.log("📝 [readPlanDocument] returning FileNotFound error for plan:", planName) return { status: "error", error: `Plan document "${planName}" does not exist. Use the create_plan tool to create it.`, @@ -77,7 +65,6 @@ export async function readPlanDocument( } } - console.log("📝 [readPlanDocument] returning generic error") return { status: "error", error: `Error reading plan document: ${errorMsg}`, @@ -96,40 +83,27 @@ export async function writePlanDocument( content: string, task: Task, ): Promise<{ canonicalPath: string } | { error: string }> { - console.log("📝 [writePlanDocument] START - relPath:", relPath, "contentLength:", content.length) const canonicalPath = normalizePlanPath(relPath) const filename = planPathToFilename(relPath) - console.log("📝 [writePlanDocument] normalized - canonicalPath:", canonicalPath, "filename:", filename) try { // Check if plan exists before writing const planFs = getPlanFileSystem() - console.log("📝 [writePlanDocument] checking if plan exists...") const wasNew = !(await planFs.planExists(canonicalPath)) - console.log("📝 [writePlanDocument] plan exists check - wasNew:", wasNew) const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) - console.log("📝 [writePlanDocument] constructed URI:", uri.toString()) const contentBytes = new TextEncoder().encode(content) - console.log("📝 [writePlanDocument] calling vscode.workspace.fs.writeFile...") await vscode.workspace.fs.writeFile(uri, contentBytes) - console.log("📝 [writePlanDocument] vscode.workspace.fs.writeFile SUCCESS") // If this is a new plan document, open it in VS Code if (wasNew) { - console.log("📝 [writePlanDocument] opening new plan document in editor") await vscode.window.showTextDocument(uri, { preview: false }) } await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) - console.log("📝 [writePlanDocument] SUCCESS - returning canonicalPath:", canonicalPath) return { canonicalPath } } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error" - console.error("📝 [writePlanDocument] ERROR:", errorMsg) - if (error instanceof Error) { - console.error("📝 [writePlanDocument] ERROR stack:", error.stack) - } return { error: `Error writing plan document: ${errorMsg}` } } } @@ -159,3 +133,56 @@ export function convertToPlanPathIfNeeded(filePath: string): string | undefined } return undefined } + +/** + * Read a plan document and return raw content. + * Simple helper for tools that need to process the content themselves. + * + * @param relPath - The plan document path (any variant) + * @returns The content or an error + */ +export async function readPlanDocumentContent(relPath: string): Promise<{ content: string } | { error: string }> { + const filename = planPathToFilename(relPath) + + try { + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + const contentBytes = await vscode.workspace.fs.readFile(uri) + const content = new TextDecoder().decode(contentBytes) + return { content } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + return { error: `Failed to read plan document: ${errorMsg}` } + } +} + +/** + * Write content to a plan document. + * Simple helper that handles URI construction and file tracking. + * + * @param relPath - The plan document path (any variant) + * @param content - The content to write + * @param task - The task instance for file tracking + * @returns The canonical path or an error + */ +export async function writePlanDocumentContent( + relPath: string, + content: string, + task: Task, +): Promise<{ canonicalPath: string } | { error: string }> { + const canonicalPath = normalizePlanPath(relPath) + const filename = planPathToFilename(relPath) + + try { + const uri = vscode.Uri.parse(`${PLAN_SCHEME_NAME}:/${filename}`) + const contentBytes = new TextEncoder().encode(content) + await vscode.workspace.fs.writeFile(uri, contentBytes) + + await task.fileContextTracker.trackFileContext(canonicalPath, "roo_edited" as RecordSource) + task.didEditFile = true + + return { canonicalPath } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error" + return { error: `Failed to write plan document: ${errorMsg}` } + } +} diff --git a/src/services/planning/PlanFileSystemProvider.ts b/src/services/planning/PlanFileSystemProvider.ts index 6374d90a32e..74db50b95a2 100644 --- a/src/services/planning/PlanFileSystemProvider.ts +++ b/src/services/planning/PlanFileSystemProvider.ts @@ -30,14 +30,9 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { constructor() { this._plansDir = path.join(os.homedir(), ".kilocode", "plans") - console.log("📝 [PlanFSP] constructor - plansDir:", this._plansDir) - fs.mkdir(this._plansDir, { recursive: true }) - .then(() => { - console.log("📝 [PlanFSP] constructor - plans directory created/verified") - }) - .catch((error) => { - console.error("📝 [PlanFSP] constructor - Failed to create plans directory:", error) - }) + fs.mkdir(this._plansDir, { recursive: true }).catch(() => { + // Silently fail - directory creation will be retried on write + }) } /** @@ -80,11 +75,9 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { async stat(uri: vscode.Uri): Promise { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) - console.log("📝 [PlanFSP] stat - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) try { const stats = await fs.stat(realPath) - console.log("📝 [PlanFSP] stat - SUCCESS, size:", stats.size) return { type: vscode.FileType.File, ctime: stats.birthtimeMs, @@ -93,7 +86,6 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { } } catch (error) { const err = error as NodeJS.ErrnoException - console.log("📝 [PlanFSP] stat - ERROR:", err.code, err.message) if (err.code === "ENOENT") { throw vscode.FileSystemError.FileNotFound(uri) } @@ -114,15 +106,12 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { async readFile(uri: vscode.Uri): Promise { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) - console.log("📝 [PlanFSP] readFile - uri:", uri.toString(), "filename:", filename, "realPath:", realPath) try { const content = await fs.readFile(realPath) - console.log("📝 [PlanFSP] readFile - SUCCESS, size:", content.length) return content } catch (error) { const err = error as NodeJS.ErrnoException - console.log("📝 [PlanFSP] readFile - ERROR:", err.code, err.message) if (err.code === "ENOENT") { throw vscode.FileSystemError.FileNotFound(uri) } @@ -137,45 +126,20 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { ): Promise { const filename = this.uriToFilename(uri) const realPath = this.getRealPath(filename) - console.log( - "📝 [PlanFSP] writeFile - START - uri:", - uri.toString(), - "filename:", - filename, - "realPath:", - realPath, - "contentSize:", - content.length, - ) // Check if file exists to determine if this is a create or update let wasNew = false try { await fs.stat(realPath) - console.log("📝 [PlanFSP] writeFile - file exists, will update") } catch { wasNew = true - console.log("📝 [PlanFSP] writeFile - file does not exist, will create") } // Ensure plans directory exists before writing - try { - await fs.mkdir(this._plansDir, { recursive: true }) - console.log("📝 [PlanFSP] writeFile - plans directory verified") - } catch (error) { - console.error("📝 [PlanFSP] writeFile - ERROR creating plans directory:", error) - throw error - } + await fs.mkdir(this._plansDir, { recursive: true }) // Write to disk - try { - await fs.writeFile(realPath, content) - console.log("📝 [PlanFSP] writeFile - SUCCESS writing to disk") - } catch (error) { - const err = error as NodeJS.ErrnoException - console.error("📝 [PlanFSP] writeFile - ERROR writing file:", err.code, err.message, err.stack) - throw error - } + await fs.writeFile(realPath, content) // Emit file change event with canonical URI const canonicalUri = this.filenameToUri(filename) @@ -184,7 +148,6 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { uri: canonicalUri, } this._emitter.fire([event]) - console.log("📝 [PlanFSP] writeFile - fired event, type:", wasNew ? "Created" : "Changed") } async delete(uri: vscode.Uri): Promise { @@ -228,42 +191,17 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { baseName = name.slice(0, -8) // Remove ".plan.md" } const filename = `${baseName}_${planId}.plan.md` - console.log( - "📝 [PlanFSP] createAndOpen - START - name:", - name, - "planId:", - planId, - "filename:", - filename, - "contentLength:", - content.length, - ) // Ensure plans directory exists - try { - await fs.mkdir(this._plansDir, { recursive: true }) - console.log("📝 [PlanFSP] createAndOpen - plans directory verified") - } catch (error) { - console.error("📝 [PlanFSP] createAndOpen - ERROR creating plans directory:", error) - throw error - } + await fs.mkdir(this._plansDir, { recursive: true }) // Store the document on disk const contentBytes = new TextEncoder().encode(content) const realPath = this.getRealPath(filename) - console.log("📝 [PlanFSP] createAndOpen - writing to realPath:", realPath) - try { - await fs.writeFile(realPath, contentBytes) - console.log("📝 [PlanFSP] createAndOpen - SUCCESS writing to disk") - } catch (error) { - const err = error as NodeJS.ErrnoException - console.error("📝 [PlanFSP] createAndOpen - ERROR writing file:", err.code, err.message, err.stack) - throw error - } + await fs.writeFile(realPath, contentBytes) // Create URI using consistent formatting const uri = this.filenameToUri(filename) - console.log("📝 [PlanFSP] createAndOpen - created URI:", uri.toString()) // Emit file change event const event: vscode.FileChangeEvent = { @@ -271,15 +209,12 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { uri, } this._emitter.fire([event]) - console.log("📝 [PlanFSP] createAndOpen - fired Created event") // Open document in VS Code editor await vscode.window.showTextDocument(uri, { preview: false }) - console.log("📝 [PlanFSP] createAndOpen - opened document in editor") // Return the plan:// path for use in tools const result = filenameToPlanPath(filename) - console.log("📝 [PlanFSP] createAndOpen - returning planPath:", result) return result } @@ -340,15 +275,11 @@ export class PlanFileSystemProvider implements vscode.FileSystemProvider { async planExists(planPath: string): Promise { const filename = planPathToFilename(planPath) const realPath = this.getRealPath(filename) - console.log("📝 [PlanFSP] planExists - planPath:", planPath, "filename:", filename, "realPath:", realPath) try { await fs.stat(realPath) - console.log("📝 [PlanFSP] planExists - file exists") return true - } catch (error) { - const err = error as NodeJS.ErrnoException - console.log("📝 [PlanFSP] planExists - file does not exist, error:", err.code) + } catch { return false } } diff --git a/src/services/planning/planPaths.ts b/src/services/planning/planPaths.ts index abd43a053f1..0c1a63e2f36 100644 --- a/src/services/planning/planPaths.ts +++ b/src/services/planning/planPaths.ts @@ -103,7 +103,6 @@ export function planPathToFilename(planPath: string): string { const afterScheme = planPath.slice(`${PLAN_SCHEME_NAME}:`.length) // Remove any leading slashes (handles //, ///, or /) const result = afterScheme.replace(/^\/+/, "") - console.log(`📝 [planPaths] planPathToFilename: "${planPath}" -> "${result}"`) return result } From 476ea735151ed0c527bbbdf7c4db2ee07f405e0f Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Thu, 8 Jan 2026 15:38:51 -0800 Subject: [PATCH 5/5] feat(experiments): add ephemeralPlanning experiment and update related functionality - Introduced the ephemeralPlanning experiment across various components, including experiment IDs and configurations. - Updated the experiment schema to include ephemeralPlanning, allowing for conditional tool exclusions based on its state. - Modified relevant tests to account for the new experiment, ensuring comprehensive coverage. - Enhanced the settings localization to provide descriptions for the ephemeralPlanning feature. This addition improves the flexibility of planning tools by enabling temporary document creation based on user settings. --- .changeset/ephemeral-planning-experiment.md | 5 +++++ packages/types/src/experiment.ts | 3 ++- src/core/prompts/tools/filter-tools-for-mode.ts | 10 ++++++++++ src/shared/__tests__/experiments.spec.ts | 3 +++ src/shared/experiments.ts | 2 ++ .../context/__tests__/ExtensionStateContext.spec.tsx | 2 ++ webview-ui/src/i18n/locales/en/settings.json | 4 ++++ 7 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .changeset/ephemeral-planning-experiment.md diff --git a/.changeset/ephemeral-planning-experiment.md b/.changeset/ephemeral-planning-experiment.md new file mode 100644 index 00000000000..e457e0f64e4 --- /dev/null +++ b/.changeset/ephemeral-planning-experiment.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add `ephemeralPlanning` experiment to gate the planning tool diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index c2936b3bfde..ef24b00a290 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -const kilocodeExperimentIds = ["morphFastApply", "speechToText"] as const // kilocode_change +const kilocodeExperimentIds = ["morphFastApply", "speechToText", "ephemeralPlanning"] as const // kilocode_change export const experimentIds = [ "powerSteering", "multiFileApplyDiff", @@ -27,6 +27,7 @@ export type ExperimentId = z.infer export const experimentsSchema = z.object({ morphFastApply: z.boolean().optional(), // kilocode_change speechToText: z.boolean().optional(), // kilocode_change + ephemeralPlanning: z.boolean().optional(), // kilocode_change powerSteering: z.boolean().optional(), multiFileApplyDiff: z.boolean().optional(), preventFocusDisruption: z.boolean().optional(), diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 4b970b26b60..71b6871675e 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -349,6 +349,11 @@ export function filterNativeToolsForMode( if (kiloCodeWrapperJetbrains || kiloCodeWrapperCode === "cli") { allowedToolNames.delete("create_plan") } + + // Conditionally exclude create_plan if ephemeralPlanning experiment is not enabled + if (!experiments?.ephemeralPlanning) { + allowedToolNames.delete("create_plan") + } // kilocode_change end - create_plan tool exclusion // Filter native tools based on allowed tool names and apply alias renames @@ -424,6 +429,11 @@ export function isToolAllowedInMode( if (toolName === "run_slash_command") { return experiments?.runSlashCommand === true } + if (toolName === "create_plan") { + // kilocode_change start - check ephemeralPlanning experiment + return experiments?.ephemeralPlanning === true + // kilocode_change end + } return true } diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index fac84fe367e..b6fb2a8b8ca 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -37,6 +37,7 @@ describe("experiments", () => { const experiments: Record = { morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + ephemeralPlanning: false, // kilocode_change powerSteering: false, multiFileApplyDiff: false, preventFocusDisruption: false, @@ -51,6 +52,7 @@ describe("experiments", () => { const experiments: Record = { morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + ephemeralPlanning: false, // kilocode_change powerSteering: true, multiFileApplyDiff: false, preventFocusDisruption: false, @@ -65,6 +67,7 @@ describe("experiments", () => { const experiments: Record = { morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + ephemeralPlanning: false, // kilocode_change powerSteering: false, multiFileApplyDiff: false, preventFocusDisruption: false, diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 3ed85c0627d..92515f7a4c7 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -3,6 +3,7 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId, Experiments } fro export const EXPERIMENT_IDS = { MORPH_FAST_APPLY: "morphFastApply", // kilocode_change SPEECH_TO_TEXT: "speechToText", // kilocode_change + EPHEMERAL_PLANNING: "ephemeralPlanning", // kilocode_change MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", POWER_STEERING: "powerSteering", PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption", @@ -22,6 +23,7 @@ interface ExperimentConfig { export const experimentConfigsMap: Record = { MORPH_FAST_APPLY: { enabled: false }, // kilocode_change SPEECH_TO_TEXT: { enabled: true }, // kilocode_change + EPHEMERAL_PLANNING: { enabled: false }, // kilocode_change MULTI_FILE_APPLY_DIFF: { enabled: false }, POWER_STEERING: { enabled: false }, PREVENT_FOCUS_DISRUPTION: { enabled: false }, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 28f64fd3a5f..c43fe6f9354 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -282,6 +282,7 @@ describe("mergeExtensionState", () => { preventFocusDisruption: false, morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + ephemeralPlanning: false, // kilocode_change newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, @@ -304,6 +305,7 @@ describe("mergeExtensionState", () => { preventFocusDisruption: false, morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + ephemeralPlanning: false, // kilocode_change newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 86922fa1b08..d63e99439b7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1019,6 +1019,10 @@ "MULTIPLE_NATIVE_TOOL_CALLS": { "name": "Parallel tool calls", "description": "When enabled, the native protocol can execute multiple tools in a single assistant message turn." + }, + "EPHEMERAL_PLANNING": { + "name": "Ephemeral planning tool", + "description": "When enabled, Kilo Code can create temporary plan documents to organize complex tasks. Plans are stored in memory and discarded when the session ends." } }, "promptCaching": {