| """Plan mode tools β EnterPlanMode and ExitPlanMode. |
| |
| EnterPlanMode switches the agent into a reasoning/planning mode where it |
| explores the codebase read-only before writing any code. |
| |
| ExitPlanMode exits planning mode and returns to normal execution mode. |
| |
| Plan state is stored in ~/.stack-2.9/plan_mode.json |
| Reasoning steps are tracked in ~/.stack-2.9/plan_reasoning.json |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| from datetime import datetime, timezone |
| from typing import Any |
|
|
| from .base import BaseTool, ToolResult |
| from .registry import get_registry |
|
|
| DATA_DIR = os.path.expanduser("~/.stack-2.9") |
| PLAN_STATE_FILE = os.path.join(DATA_DIR, "plan_mode.json") |
| REASONING_FILE = os.path.join(DATA_DIR, "plan_reasoning.json") |
|
|
|
|
| def _load_plan_state() -> dict[str, Any]: |
| os.makedirs(DATA_DIR, exist_ok=True) |
| if os.path.exists(PLAN_STATE_FILE): |
| try: |
| with open(PLAN_STATE_FILE) as f: |
| return json.load(f) |
| except Exception: |
| pass |
| return {"active": False, "entered_at": None, "plan_text": None, "context": None} |
|
|
|
|
| def _save_plan_state(state: dict[str, Any]) -> None: |
| os.makedirs(DATA_DIR, exist_ok=True) |
| with open(PLAN_STATE_FILE, "w") as f: |
| json.dump(state, f, indent=2, default=str) |
|
|
|
|
| def _load_reasoning() -> list[dict[str, Any]]: |
| os.makedirs(DATA_DIR, exist_ok=True) |
| if os.path.exists(REASONING_FILE): |
| try: |
| with open(REASONING_FILE) as f: |
| return json.load(f) |
| except Exception: |
| pass |
| return [] |
|
|
|
|
| def _save_reasoning(steps: list[dict[str, Any]]) -> None: |
| os.makedirs(DATA_DIR, exist_ok=True) |
| with open(REASONING_FILE, "w") as f: |
| json.dump(steps, f, indent=2, default=str) |
|
|
|
|
| |
|
|
|
|
| class EnterPlanModeTool(BaseTool[dict[str, Any], dict[str, Any]]): |
| """Enter plan mode β a read-only reasoning phase for exploring and designing. |
| |
| Parameters |
| ---------- |
| plan_text : str, optional |
| Initial plan text to record. |
| context : str, optional |
| Context or task description for the plan. |
| """ |
|
|
| name = "EnterPlanMode" |
| description = ( |
| "Switch to plan mode for complex tasks requiring exploration and design. " |
| "In plan mode, you should explore the codebase read-only and design an approach " |
| "before writing any code. Use ExitPlanMode when ready to present your plan." |
| ) |
| search_hint = "switch to plan mode to design approach before coding" |
|
|
| @property |
| def input_schema(self) -> dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "plan_text": { |
| "type": "string", |
| "description": "Initial plan text or summary to record", |
| }, |
| "context": { |
| "type": "string", |
| "description": "Context or task description guiding the plan", |
| }, |
| }, |
| "properties": {}, |
| } |
|
|
| def is_enabled(self) -> bool: |
| state = _load_plan_state() |
| return not state.get("active", False) |
|
|
| def execute(self, input_data: dict[str, Any]) -> ToolResult[dict[str, Any]]: |
| state = _load_plan_state() |
| if state.get("active"): |
| return ToolResult(success=False, error="Already in plan mode. Use ExitPlanMode first.") |
|
|
| now = datetime.now(timezone.utc).isoformat() |
| plan_text = input_data.get("plan_text", "") |
| context = input_data.get("context", "") |
|
|
| new_state = { |
| "active": True, |
| "entered_at": now, |
| "plan_text": plan_text, |
| "context": context, |
| "exited_at": None, |
| } |
| _save_plan_state(new_state) |
|
|
| |
| reasoning = _load_reasoning() |
| reasoning.append({ |
| "step": 1, |
| "action": "enter_plan_mode", |
| "timestamp": now, |
| "context": context, |
| "note": "Entered plan mode. Begin read-only exploration and design.", |
| }) |
| _save_reasoning(reasoning) |
|
|
| return ToolResult( |
| success=True, |
| data={ |
| "message": "Entered plan mode. Explore the codebase read-only and design your implementation approach.", |
| "plan_text": plan_text, |
| "context": context, |
| "entered_at": now, |
| }, |
| ) |
|
|
| def map_result_to_message(self, result: dict, tool_use_id: str | None = None) -> str: |
| data = result.get("data", {}) |
| msg = data.get( |
| "message", |
| "Entered plan mode. Explore the codebase read-only and design your approach.", |
| ) |
| return f"""{msg} |
| |
| In plan mode, you should: |
| 1. Explore the codebase to understand existing patterns |
| 2. Identify similar features and architectural approaches |
| 3. Consider multiple approaches and trade-offs |
| 4. Use FileReadTool to understand the structure |
| 5. Design a concrete implementation strategy |
| 6. When ready, use ExitPlanMode to present your plan |
| |
| DO NOT write or edit any files yet. This is a read-only exploration phase.""" |
|
|
|
|
| |
|
|
|
|
| class ExitPlanModeTool(BaseTool[dict[str, Any], dict[str, Any]]): |
| """Exit plan mode and return to normal execution. |
| |
| Parameters |
| ---------- |
| confirm : bool, optional |
| Whether the plan is approved (default: True). |
| summary : str, optional |
| A summary or the full plan text to save. |
| """ |
|
|
| name = "ExitPlanMode" |
| description = ( |
| "Exit plan mode and return to normal execution. " |
| "Call this when you have finished your plan and are ready to code, " |
| "or to abandon the plan without implementing." |
| ) |
| search_hint = "exit plan mode and start coding present plan for approval" |
|
|
| @property |
| def input_schema(self) -> dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "confirm": { |
| "type": "boolean", |
| "description": "Whether the plan is approved (default: True)", |
| "default": True, |
| }, |
| "summary": { |
| "type": "string", |
| "description": "Plan summary or full plan text to save", |
| }, |
| }, |
| "properties": {}, |
| } |
|
|
| def execute(self, input_data: dict[str, Any]) -> ToolResult[dict[str, Any]]: |
| state = _load_plan_state() |
| if not state.get("active"): |
| return ToolResult(success=False, error="Not in plan mode. Use EnterPlanMode first.") |
|
|
| confirm = input_data.get("confirm", True) |
| summary = input_data.get("summary") or state.get("plan_text", "") |
|
|
| now = datetime.now(timezone.utc).isoformat() |
|
|
| |
| reasoning = _load_reasoning() |
| reasoning.append({ |
| "step": len(reasoning) + 1, |
| "action": "exit_plan_mode", |
| "timestamp": now, |
| "confirm": confirm, |
| "summary_length": len(summary) if summary else 0, |
| "note": "Exited plan mode" + (" (plan approved)" if confirm else " (plan rejected/abandoned)"), |
| }) |
| _save_reasoning(reasoning) |
|
|
| |
| new_state = { |
| **state, |
| "active": False, |
| "exited_at": now, |
| "plan_text": summary if summary else state.get("plan_text"), |
| "approved": confirm, |
| } |
| _save_plan_state(new_state) |
|
|
| return ToolResult( |
| success=True, |
| data={ |
| "message": "Exited plan mode. Ready to proceed." if confirm else "Plan abandoned.", |
| "plan_text": summary, |
| "confirmed": confirm, |
| "exited_at": now, |
| }, |
| ) |
|
|
| def map_result_to_message(self, result: dict, tool_use_id: str | None = None) -> str: |
| data = result.get("data", {}) |
| confirm = data.get("confirmed", True) |
| plan_text = data.get("plan_text", "") |
|
|
| if confirm: |
| lines = ["Plan approved. You can now start coding."] |
| if plan_text: |
| lines.append(f"\nPlan saved:\n{plan_text}") |
| return "\n".join(lines) |
| else: |
| return "Plan abandoned. Exited plan mode." |
|
|
|
|
| |
| get_registry().register(EnterPlanModeTool()) |
| get_registry().register(ExitPlanModeTool()) |
|
|
|
|