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>
This commit is contained in:
@@ -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
|
||||
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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit");
|
||||
private static final Set<String> 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<String> 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<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();
|
||||
|
||||
// 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<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...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user