feat: Plan Mode implementation (Phase 2A)

- EnterPlanModeTool: switch to read-only mode with plan file path management
- ExitPlanModeTool: restore permissions and validate plan content
- PermissionRuleEngine: plan file edit exception in PLAN mode
- SystemPromptBuilder: 5-phase plan workflow instructions injection
- /plan command: toggle plan mode from command line
- Plan file stored at ~/.claude/projects/[path]/PLAN.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent bd98dea6b3
commit cb76b267f1
  1. 48
      src/main/java/com/claudecode/command/impl/PlanCommand.java
  2. 8
      src/main/java/com/claudecode/config/AppConfig.java
  3. 84
      src/main/java/com/claudecode/context/SystemPromptBuilder.java
  4. 53
      src/main/java/com/claudecode/permission/PermissionRuleEngine.java
  5. 150
      src/main/java/com/claudecode/tool/impl/EnterPlanModeTool.java
  6. 127
      src/main/java/com/claudecode/tool/impl/ExitPlanModeTool.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
* <p>
* 切换计划模式开关在计划模式下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.";
}
}
}

@ -79,10 +79,11 @@ public class AppConfig {
@Bean @Bean
public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager,
ToolContext toolContext) { ToolContext toolContext, PermissionSettings permissionSettings) {
// 将 TaskManager 和 McpManager 注册到 ToolContext 供工具使用 // 将 TaskManager 和 McpManager 注册到 ToolContext 供工具使用
toolContext.set("TASK_MANAGER", taskManager); toolContext.set("TASK_MANAGER", taskManager);
toolContext.set("MCP_MANAGER", mcpManager); toolContext.set("MCP_MANAGER", mcpManager);
toolContext.set("PERMISSION_SETTINGS", permissionSettings);
ToolRegistry registry = new ToolRegistry(); ToolRegistry registry = new ToolRegistry();
registry.registerAll( registry.registerAll(
@ -110,7 +111,9 @@ public class AppConfig {
new ConfigTool(), new ConfigTool(),
// P2: 实用工具 // P2: 实用工具
new SleepTool(), new SleepTool(),
new ToolSearchTool() new ToolSearchTool(),
new EnterPlanModeTool(),
new ExitPlanModeTool()
); );
// P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具)
@ -156,6 +159,7 @@ public class AppConfig {
new FilesCommand(), new FilesCommand(),
new PermissionsCommand(permissionSettings), new PermissionsCommand(permissionSettings),
new TasksCommand(taskManager), new TasksCommand(taskManager),
new PlanCommand(permissionSettings),
// P2 命令 // P2 命令
new HooksCommand(), new HooksCommand(),
new ReviewCommand(), new ReviewCommand(),

@ -30,6 +30,9 @@ public class SystemPromptBuilder {
private String skillsSummary; private String skillsSummary;
private String gitSummary; private String gitSummary;
private String languagePreference; private String languagePreference;
private boolean planMode;
private String planFilePath;
private String sessionMemory;
public SystemPromptBuilder() { public SystemPromptBuilder() {
this.workDir = System.getProperty("user.dir"); this.workDir = System.getProperty("user.dir");
@ -67,6 +70,17 @@ public class SystemPromptBuilder {
return this; 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"); 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(); return sb.toString();
} }
@ -363,4 +389,62 @@ public class SystemPromptBuilder {
sb.append("\n"); sb.append("\n");
return sb.toString(); 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);
}
} }

@ -1,6 +1,7 @@
package com.claudecode.permission; package com.claudecode.permission;
import com.claudecode.permission.PermissionTypes.*; import com.claudecode.permission.PermissionTypes.*;
import com.claudecode.tool.impl.EnterPlanModeTool;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -25,9 +26,13 @@ public class PermissionRuleEngine {
private static final Set<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit"); private static final Set<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit");
private static final Set<String> READ_ONLY_TOOLS = Set.of( private static final Set<String> READ_ONLY_TOOLS = Set.of(
"Read", "Glob", "Grep", "ListFiles", "WebFetch", "WebSearch", "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<String> PLAN_FILE_TOOLS = Set.of("Write", "Edit");
private final PermissionSettings settings; private final PermissionSettings settings;
public PermissionRuleEngine(PermissionSettings settings) { public PermissionRuleEngine(PermissionSettings settings) {
@ -43,6 +48,14 @@ public class PermissionRuleEngine {
* @return 权限决策 * @return 权限决策
*/ */
public PermissionDecision evaluate(String toolName, Map<String, Object> input, boolean isReadOnly) { public PermissionDecision evaluate(String toolName, Map<String, Object> input, boolean isReadOnly) {
return evaluate(toolName, input, isReadOnly, null);
}
/**
* 评估工具调用的权限 ToolContext 用于 plan 模式检查
*/
public PermissionDecision evaluate(String toolName, Map<String, Object> input,
boolean isReadOnly, Object toolContext) {
PermissionMode mode = settings.getCurrentMode(); PermissionMode mode = settings.getCurrentMode();
// BYPASS 模式:全部允许 // BYPASS 模式:全部允许
@ -50,11 +63,15 @@ public class PermissionRuleEngine {
return PermissionDecision.allow("Bypass mode enabled"); return PermissionDecision.allow("Bypass mode enabled");
} }
// PLAN 模式:仅允许只读工具 // PLAN 模式:仅允许只读工具 + plan文件编辑
if (mode == PermissionMode.PLAN) { if (mode == PermissionMode.PLAN) {
if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) { if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) {
return PermissionDecision.allow("Read-only tool allowed in plan mode"); 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)"); return PermissionDecision.deny("Plan mode: execution disabled (analysis only)");
} }
@ -178,4 +195,36 @@ public class PermissionRuleEngine {
int space = trimmed.indexOf(' '); int space = trimmed.indexOf(' ');
return space > 0 ? trimmed.substring(0, space) : trimmed; return space > 0 ? trimmed.substring(0, space) : trimmed;
} }
/**
* 检查文件操作是否针对 plan 文件
* PLAN 模式中只有 PLAN.md 可以被编辑
*/
private boolean isPlanFileOperation(Map<String, Object> 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;
}
} }

@ -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
* <p>
* 将权限切换到只读模式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<String, Object> 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<String, Object> 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);
}
}

@ -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
* <p>
* 验证计划文件内容恢复之前的权限模式
*/
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<String, Object> 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<String, Object> input) {
return "Exiting plan mode...";
}
}
Loading…
Cancel
Save