diff --git a/src/main/java/com/claudecode/command/impl/PlanCommand.java b/src/main/java/com/claudecode/command/impl/PlanCommand.java new file mode 100644 index 0000000..88374ab --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/PlanCommand.java @@ -0,0 +1,48 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.permission.PermissionSettings; +import com.claudecode.permission.PermissionTypes.PermissionMode; + +/** + * /plan 命令 —— 对应 claude-code/src/commands/plan/plan.tsx。 + *

+ * 切换计划模式开关。在计划模式下,AI只能分析不能修改。 + */ +public class PlanCommand implements SlashCommand { + + private final PermissionSettings permissionSettings; + + public PlanCommand(PermissionSettings permissionSettings) { + this.permissionSettings = permissionSettings; + } + + @Override + public String name() { + return "plan"; + } + + @Override + public String description() { + return "Toggle plan mode (analysis only, no file modifications)"; + } + + @Override + public String execute(String args, CommandContext context) { + PermissionMode currentMode = permissionSettings.getCurrentMode(); + + if (currentMode == PermissionMode.PLAN) { + // Exit plan mode + permissionSettings.setCurrentMode(PermissionMode.DEFAULT); + return "📋 Exited plan mode. Normal permissions restored.\n" + + "All tools are now available."; + } else { + // Enter plan mode + permissionSettings.setCurrentMode(PermissionMode.PLAN); + return "📋 Entered plan mode.\n" + + "Only read-only tools are available. Use /plan again to exit.\n" + + "Or ask the AI to call EnterPlanMode for the full workflow."; + } + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 11b6a88..d57f0e5 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -79,10 +79,11 @@ public class AppConfig { @Bean public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, - ToolContext toolContext) { + ToolContext toolContext, PermissionSettings permissionSettings) { // 将 TaskManager 和 McpManager 注册到 ToolContext 供工具使用 toolContext.set("TASK_MANAGER", taskManager); toolContext.set("MCP_MANAGER", mcpManager); + toolContext.set("PERMISSION_SETTINGS", permissionSettings); ToolRegistry registry = new ToolRegistry(); registry.registerAll( @@ -110,7 +111,9 @@ public class AppConfig { new ConfigTool(), // P2: 实用工具 new SleepTool(), - new ToolSearchTool() + new ToolSearchTool(), + new EnterPlanModeTool(), + new ExitPlanModeTool() ); // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) @@ -156,6 +159,7 @@ public class AppConfig { new FilesCommand(), new PermissionsCommand(permissionSettings), new TasksCommand(taskManager), + new PlanCommand(permissionSettings), // P2 命令 new HooksCommand(), new ReviewCommand(), diff --git a/src/main/java/com/claudecode/context/SystemPromptBuilder.java b/src/main/java/com/claudecode/context/SystemPromptBuilder.java index 4a7658c..b006e6b 100644 --- a/src/main/java/com/claudecode/context/SystemPromptBuilder.java +++ b/src/main/java/com/claudecode/context/SystemPromptBuilder.java @@ -30,6 +30,9 @@ public class SystemPromptBuilder { private String skillsSummary; private String gitSummary; private String languagePreference; + private boolean planMode; + private String planFilePath; + private String sessionMemory; public SystemPromptBuilder() { this.workDir = System.getProperty("user.dir"); @@ -67,6 +70,17 @@ public class SystemPromptBuilder { return this; } + public SystemPromptBuilder planMode(boolean active, String planFilePath) { + this.planMode = active; + this.planFilePath = planFilePath; + return this; + } + + public SystemPromptBuilder sessionMemory(String sessionMemory) { + this.sessionMemory = sessionMemory; + return this; + } + /** * 构建完整的系统提示词。 */ @@ -130,6 +144,18 @@ public class SystemPromptBuilder { sb.append(customInstructions).append("\n\n"); } + // ── 10. Plan Mode Instructions (对应 TS getPlanModeInstructions) ── + if (planMode) { + sb.append(getPlanModeSection()); + } + + // ── 11. Session Memory (对应 TS SessionMemory) ── + if (sessionMemory != null && !sessionMemory.isBlank()) { + sb.append("# Session Memory\n"); + sb.append("The following is a summary of key information from this conversation:\n"); + sb.append(sessionMemory).append("\n\n"); + } + return sb.toString(); } @@ -363,4 +389,62 @@ public class SystemPromptBuilder { sb.append("\n"); return sb.toString(); } + + /** + * 对应 TS getPlanModeInstructions()。 + * 计划模式5阶段工作流指导。 + */ + private String getPlanModeSection() { + String planPath = planFilePath != null ? planFilePath : "~/.claude/projects/PLAN.md"; + return """ + # Plan Mode Active + + The user indicated they do NOT want execution yet. They want you to analyze and plan first. + YOU MUST NOT make any edits (except the plan file), run shell commands, or make changes. + + ## Plan File + Location: %s + This is the ONLY file you may create or edit in plan mode. + + ## Plan Workflow (5 Phases) + + ### Phase 1: Initial Understanding + - Use read-only tools (Read, Grep, Glob, ListFiles) to explore the codebase + - Find existing implementations and reusable patterns + - Understand the project structure and conventions + + ### Phase 2: Design + - Based on your understanding, design the implementation approach + - Consider multiple perspectives and trade-offs + - Identify potential risks and edge cases + + ### Phase 3: Review + - Read critical files you identified + - Ensure your plan aligns with the user's original request + - Use AskUserQuestion to clarify any ambiguous requirements + + ### Phase 4: Write the Plan + - Write your plan to the plan file ONLY + - Include these sections: + - **Context**: Why this change is needed (problem/need/outcome) + - **Recommended Approach**: Single recommended approach (not all alternatives) + - **File Paths**: Critical files to be modified + - **Existing Utilities**: Functions to reuse (with file paths) + - **Verification**: Command to test the changes end-to-end + - Keep the plan concise but detailed (~40 lines typical) + + ### Phase 5: Exit Plan Mode + - Call ExitPlanMode with a brief summary + - The user will review and approve the plan + - Do NOT use AskUserQuestion for plan approval — call ExitPlanMode instead + + ## Important Reminders + - You can ONLY use: Read, Grep, Glob, ListFiles, WebFetch, WebSearch, AskUserQuestion + - You can ONLY write to: %s + - Do NOT run Bash commands + - Do NOT edit any source files + - Do NOT use FileWrite or FileEdit except for the plan file + + """.formatted(planPath, planPath); + } } diff --git a/src/main/java/com/claudecode/permission/PermissionRuleEngine.java b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java index 8554c27..f6486ed 100644 --- a/src/main/java/com/claudecode/permission/PermissionRuleEngine.java +++ b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java @@ -1,6 +1,7 @@ package com.claudecode.permission; import com.claudecode.permission.PermissionTypes.*; +import com.claudecode.tool.impl.EnterPlanModeTool; import java.util.List; import java.util.Map; @@ -25,9 +26,13 @@ public class PermissionRuleEngine { private static final Set FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit"); private static final Set READ_ONLY_TOOLS = Set.of( "Read", "Glob", "Grep", "ListFiles", "WebFetch", "WebSearch", - "TodoRead", "TaskGet", "TaskList", "AskUserQuestion" + "TodoRead", "TaskGet", "TaskList", "AskUserQuestion", + "EnterPlanMode", "ExitPlanMode", "ToolSearch" ); + /** Tools allowed to operate on plan file during PLAN mode */ + private static final Set PLAN_FILE_TOOLS = Set.of("Write", "Edit"); + private final PermissionSettings settings; public PermissionRuleEngine(PermissionSettings settings) { @@ -43,6 +48,14 @@ public class PermissionRuleEngine { * @return 权限决策 */ public PermissionDecision evaluate(String toolName, Map input, boolean isReadOnly) { + return evaluate(toolName, input, isReadOnly, null); + } + + /** + * 评估工具调用的权限(带 ToolContext 用于 plan 模式检查) + */ + public PermissionDecision evaluate(String toolName, Map input, + boolean isReadOnly, Object toolContext) { PermissionMode mode = settings.getCurrentMode(); // BYPASS 模式:全部允许 @@ -50,11 +63,15 @@ public class PermissionRuleEngine { return PermissionDecision.allow("Bypass mode enabled"); } - // PLAN 模式:仅允许只读工具 + // PLAN 模式:仅允许只读工具 + plan文件编辑 if (mode == PermissionMode.PLAN) { if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) { return PermissionDecision.allow("Read-only tool allowed in plan mode"); } + // Allow Write/Edit to plan file only + if (PLAN_FILE_TOOLS.contains(toolName) && isPlanFileOperation(input, toolContext)) { + return PermissionDecision.allow("Plan file edit allowed in plan mode"); + } return PermissionDecision.deny("Plan mode: execution disabled (analysis only)"); } @@ -178,4 +195,36 @@ public class PermissionRuleEngine { int space = trimmed.indexOf(' '); return space > 0 ? trimmed.substring(0, space) : trimmed; } + + /** + * 检查文件操作是否针对 plan 文件。 + * 在 PLAN 模式中,只有 PLAN.md 可以被编辑。 + */ + private boolean isPlanFileOperation(Map input, Object toolContext) { + if (input == null) return false; + String filePath = (String) input.get("file_path"); + if (filePath == null) return false; + + // Check if the file path ends with PLAN.md + if (filePath.endsWith("PLAN.md") || filePath.endsWith("plan.md")) { + return true; + } + + // If we have toolContext, check against stored plan file path + if (toolContext != null) { + try { + var ctx = (com.claudecode.tool.ToolContext) toolContext; + String planPath = ctx.get(EnterPlanModeTool.PLAN_FILE_PATH_KEY); + if (planPath != null) { + var targetPath = java.nio.file.Path.of(filePath).toAbsolutePath().normalize(); + var planFilePath = java.nio.file.Path.of(planPath).toAbsolutePath().normalize(); + return targetPath.equals(planFilePath); + } + } catch (Exception e) { + // Ignore cast/access errors + } + } + + return false; + } } diff --git a/src/main/java/com/claudecode/tool/impl/EnterPlanModeTool.java b/src/main/java/com/claudecode/tool/impl/EnterPlanModeTool.java new file mode 100644 index 0000000..7e8d479 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/EnterPlanModeTool.java @@ -0,0 +1,150 @@ +package com.claudecode.tool.impl; + +import com.claudecode.permission.PermissionSettings; +import com.claudecode.permission.PermissionTypes.PermissionMode; +import com.claudecode.tool.ToolContext; +import com.claudecode.tool.Tool; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * 进入计划模式工具 —— 对应 claude-code/src/tools/EnterPlanModeTool。 + *

+ * 将权限切换到只读模式,AI只能分析代码不能修改, + * 只有计划文件(PLAN.md)可以编辑。 + */ +public class EnterPlanModeTool implements Tool { + + public static final String PLAN_MODE_KEY = "PLAN_MODE_ACTIVE"; + public static final String PLAN_FILE_PATH_KEY = "PLAN_FILE_PATH"; + public static final String PRE_PLAN_MODE_KEY = "PRE_PLAN_MODE"; + + @Override + public String name() { + return "EnterPlanMode"; + } + + @Override + public String description() { + return """ + Enter plan mode to analyze the codebase and design an implementation plan WITHOUT making changes. + + When to use: + - User asks you to "plan" or "think about" a change before implementing + - User wants to understand approach before committing to it + - Complex multi-file changes that need careful design + + In plan mode: + - You can ONLY use read-only tools (Read, Grep, Glob, ListFiles, WebFetch, WebSearch) + - You can ONLY write to the plan file (PLAN.md) + - All other file modifications and shell commands are BLOCKED + - Use AskUserQuestion to clarify requirements + - Call ExitPlanMode when the plan is complete + + The plan file location is determined automatically based on the project path. + """; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Brief reason for entering plan mode" + } + }, + "required": [] + } + """; + } + + @Override + public String execute(Map input, ToolContext context) { + // Check if already in plan mode + Boolean active = context.getOrDefault(PLAN_MODE_KEY, false); + if (active) { + String existingPlan = context.get(PLAN_FILE_PATH_KEY); + return "Already in plan mode. Plan file: " + existingPlan; + } + + // Determine plan file path + Path workDir = context.getWorkDir(); + Path planDir = getPlanDirectory(workDir); + Path planFile = planDir.resolve("PLAN.md"); + + // Save pre-plan mode for restoration + PermissionSettings permSettings = context.get("PERMISSION_SETTINGS"); + if (permSettings != null) { + PermissionMode previousMode = permSettings.getCurrentMode(); + context.set(PRE_PLAN_MODE_KEY, previousMode); + // Switch to PLAN mode + permSettings.setCurrentMode(PermissionMode.PLAN); + } + + // Store plan state + context.set(PLAN_MODE_KEY, true); + context.set(PLAN_FILE_PATH_KEY, planFile.toString()); + + // Create plan directory if needed + try { + Files.createDirectories(planDir); + } catch (Exception e) { + // Non-fatal + } + + String reason = input != null ? (String) input.get("reason") : null; + boolean planExists = Files.exists(planFile); + + StringBuilder result = new StringBuilder(); + result.append("✅ Entered plan mode.\n\n"); + result.append("📋 Plan file: ").append(planFile).append("\n"); + if (planExists) { + result.append("📄 Existing plan found — you can read and update it.\n"); + } else { + result.append("📝 No existing plan — create one by writing to the plan file.\n"); + } + result.append("\n"); + result.append("Restrictions active:\n"); + result.append(" • Only read-only tools allowed (Read, Grep, Glob, etc.)\n"); + result.append(" • Only the plan file can be edited\n"); + result.append(" • Shell commands are blocked\n"); + result.append(" • Call ExitPlanMode when your plan is ready\n"); + + if (reason != null && !reason.isBlank()) { + result.append("\nReason: ").append(reason); + } + + return result.toString(); + } + + @Override + public boolean isReadOnly() { + // This tool itself doesn't modify files + return true; + } + + @Override + public String activityDescription(Map input) { + return "Entering plan mode..."; + } + + /** + * Get plan directory for the given work directory. + * Uses ~/.claude/projects/[sanitized-path]/ structure. + */ + static Path getPlanDirectory(Path workDir) { + String sanitized = workDir.toAbsolutePath().toString() + .replace(":", "_") + .replace("\\", "_") + .replace("/", "_"); + return Path.of(System.getProperty("user.home")) + .resolve(".claude") + .resolve("projects") + .resolve(sanitized); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/ExitPlanModeTool.java b/src/main/java/com/claudecode/tool/impl/ExitPlanModeTool.java new file mode 100644 index 0000000..12e9d39 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/ExitPlanModeTool.java @@ -0,0 +1,127 @@ +package com.claudecode.tool.impl; + +import com.claudecode.permission.PermissionSettings; +import com.claudecode.permission.PermissionTypes.PermissionMode; +import com.claudecode.tool.ToolContext; +import com.claudecode.tool.Tool; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * 退出计划模式工具 —— 对应 claude-code/src/tools/ExitPlanModeTool。 + *

+ * 验证计划文件内容,恢复之前的权限模式。 + */ +public class ExitPlanModeTool implements Tool { + + @Override + public String name() { + return "ExitPlanMode"; + } + + @Override + public String description() { + return """ + Exit plan mode after completing your implementation plan. + + When to use: + - You have finished writing the plan to the plan file + - The plan includes: context, approach, file paths, and verification steps + - You are ready for the user to review and approve the plan + + This will: + - Restore normal permission mode (all tools available again) + - Present the plan to the user for review + - The user can then ask you to implement the plan + + Do NOT call this tool until the plan is written to the plan file. + """; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Brief summary of the plan (1-2 sentences)" + } + }, + "required": ["summary"] + } + """; + } + + @Override + public String execute(Map input, ToolContext context) { + // Check if in plan mode + Boolean active = context.getOrDefault(EnterPlanModeTool.PLAN_MODE_KEY, false); + if (!active) { + return "⚠️ Not currently in plan mode. Nothing to exit."; + } + + String planFilePath = context.get(EnterPlanModeTool.PLAN_FILE_PATH_KEY); + String summary = input != null ? (String) input.get("summary") : null; + + // Validate plan file exists and has content + String planContent = null; + if (planFilePath != null) { + Path planFile = Path.of(planFilePath); + if (Files.exists(planFile)) { + try { + planContent = Files.readString(planFile); + } catch (Exception e) { + // Non-fatal + } + } + } + + // Restore previous mode + PermissionSettings permSettings = context.get("PERMISSION_SETTINGS"); + if (permSettings != null) { + PermissionMode previousMode = context.getOrDefault( + EnterPlanModeTool.PRE_PLAN_MODE_KEY, PermissionMode.DEFAULT); + permSettings.setCurrentMode(previousMode); + } + + // Clear plan mode state + context.set(EnterPlanModeTool.PLAN_MODE_KEY, false); + + StringBuilder result = new StringBuilder(); + result.append("✅ Exited plan mode. Normal permissions restored.\n\n"); + + if (planContent != null && !planContent.isBlank()) { + int lines = (int) planContent.lines().count(); + int chars = planContent.length(); + result.append("📋 Plan file: ").append(planFilePath).append("\n"); + result.append("📊 Plan size: ").append(lines).append(" lines, ") + .append(chars).append(" characters\n"); + } else { + result.append("⚠️ Warning: Plan file is empty or missing.\n"); + result.append(" Path: ").append(planFilePath).append("\n"); + } + + if (summary != null && !summary.isBlank()) { + result.append("\n📝 Summary: ").append(summary).append("\n"); + } + + result.append("\nThe user can now review the plan and ask you to implement it."); + + return result.toString(); + } + + @Override + public boolean isReadOnly() { + // This tool itself doesn't modify files, just state + return true; + } + + @Override + public String activityDescription(Map input) { + return "Exiting plan mode..."; + } +}