diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index a4db3a464..fc2f7a4eb 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -670,6 +670,41 @@ def _interactive_sigint_guard(_sig, _frame): return +def _process_plan_step_markers(agent_response: str) -> str: + """Check the agent response for PLAN_STEP_DONE markers and update the plan file. + + This runs after every LLM turn so /plan run [N] can auto-check steps off. + Returns the response with the markers stripped. + """ + import re + + from code_puppy.command_line.plan_manager import PlanManager + from code_puppy.messaging import emit_success + + pm = PlanManager() + updated = False + lines = agent_response.split("\n") + filtered: list[str] = [] + + for line in lines: + m = re.match(r"PLAN_STEP_DONE:\s*(\d+)", line.strip()) + if m: + step_num = int(m.group(1)) + if pm.mark_step_done(step_num, done=True): + emit_success(f"✅ Step {step_num} marked as done in the plan.") + updated = True + # Don't include the marker in the response to the user + continue + filtered.append(line) + + if updated: + from code_puppy.messaging import emit_info + + emit_info("Plan updated in .claude/plan.md") + + return "\n".join(filtered) + + async def interactive_mode(message_renderer, initial_command: str = None) -> None: """Run the agent in interactive mode.""" from code_puppy.command_line.command_handler import handle_command @@ -1132,6 +1167,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Get the structured response agent_response = result.output + # Process PLAN_STEP_DONE markers from plan execution steps + agent_response = _process_plan_step_markers(agent_response) + # Emit structured message for proper markdown rendering from code_puppy.messaging import get_message_bus from code_puppy.messaging.messages import AgentResponseMessage @@ -1247,6 +1285,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non break agent_response = result.output + agent_response = _process_plan_step_markers(agent_response) response_msg = AgentResponseMessage( content=agent_response, is_markdown=True, diff --git a/code_puppy/command_line/core_commands.py b/code_puppy/command_line/core_commands.py index 4b2f3353f..91dcf1313 100644 --- a/code_puppy/command_line/core_commands.py +++ b/code_puppy/command_line/core_commands.py @@ -12,9 +12,10 @@ interactive_model_picker, update_model_in_input, ) +from code_puppy.command_line.plan_manager import PlanManager from code_puppy.command_line.utils import make_directory_table from code_puppy.config import finalize_autosave_session -from code_puppy.messaging import emit_error, emit_info +from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning from code_puppy.tools.tools_content import tools_content @@ -615,31 +616,141 @@ def handle_mcp_command(command: str) -> bool: @register_command( name="plan", - description="Create a plan-only response without executing tools", - usage="/plan ", + description="Plan, persist, and execute — plan-only mode with step-by-step execution", + usage="/plan | show | edit | run [N] | mark ", + aliases=["p"], category="core", ) def handle_plan_command(command: str) -> bool | str: - """Build a planning prompt and route it to the main chat pipeline.""" + """Planning command with file persistence and step execution. + + Subcommands: + /plan — Enter plan-only mode. LLM explores and outputs a + structured checklist plan, then saves it to .claude/plan.md. + /plan show — Display the current .claude/plan.md. + /plan edit — Open .claude/plan.md in $EDITOR. + /plan run [N]— Execute one or all pending steps from the plan. + /plan mark — Toggle step N as done/undone in the plan file. + """ parts = command.split(maxsplit=1) - goal = parts[1].strip() if len(parts) > 1 else "" + subcommand = parts[1].strip() if len(parts) > 1 else "" + + if not subcommand: + emit_error( + "Usage: /plan | /plan show | /plan edit | /plan run [N] | /plan mark " + ) + return True + + pm = PlanManager() - if not goal: - emit_error("Usage: /plan ") + # ── /plan show ──────────────────────────────────────────────────────── + if subcommand == "show": + output = pm.show_formatted() + emit_info(output) return True + # ── /plan edit ──────────────────────────────────────────────────────── + if subcommand == "edit": + try: + if pm.exists(): + emit_info("Opening .claude/plan.md in your editor…") + else: + emit_info("No plan file yet. Creating a template…") + if pm.edit_in_editor(): + emit_success("Plan saved.") + else: + emit_warning( + "Editor exited with an error; plan may not have been saved." + ) + except RuntimeError as e: + emit_error(str(e)) + return True + + # ── /plan run ───────────────────────────────────────────────────────── + if subcommand.startswith("run"): + if not pm.exists(): + emit_error("No plan found. Create one with /plan or /plan edit.") + return True + + # Parse optional step number + rest = subcommand[len("run") :].strip() + step_num = None + if rest and rest.isdigit(): + step_num = int(rest) + + try: + prompt = pm.build_run_prompt(step_num) + return prompt # → LLM executes the step(s) + except ValueError as e: + emit_error(str(e)) + return True + + # ── /plan mark ──────────────────────────────────────────────────── + if subcommand.startswith("mark") or subcommand.startswith("done"): + rest = subcommand.split(maxsplit=1) + if len(rest) < 2 or not rest[1].isdigit(): + emit_error("Usage: /plan mark ") + return True + step_num = int(rest[1]) + steps = pm.get_steps() + target = next((s for s in steps if s["index"] == step_num), None) + if not target: + emit_error(f"Step {step_num} not found in the plan.") + return True + new_state = not target["done"] + if pm.mark_step_done(step_num, new_state): + status = "done" if new_state else "pending" + emit_success(f"Step {step_num} marked as {status}.") + return True + + # ── /plan run (no space, e.g. /plan run3) ───────────────────────── + # Catch /plan run3 or /plan 3 (single digit remainder) + if subcommand.lstrip("-").isdigit(): + step_num = int(subcommand) + if not pm.exists(): + emit_error("No plan found. Create one with /plan or /plan edit.") + return True + try: + prompt = pm.build_run_prompt(step_num) + return prompt + except ValueError as e: + emit_error(str(e)) + return True + + # ── /plan — planning prompt ──────────────────────────────────── + # Anything else is the goal text. + goal = subcommand + planning_prompt = f"""You are in plan-only mode. -Do not execute tools, do not modify files, and do not run shell commands. + +You may read files and explore the codebase freely. You may NOT modify any +source files or run destructive commands. However, you ARE allowed to write +the final plan to .claude/plan.md using the Write tool — that is the ONLY +file you may create or edit. User goal: {goal} -Return only: -1. A concise objective summary -2. A numbered implementation plan -3. Risks/unknowns -4. Validation checklist -5. Optional follow-up questions (only if needed) +Return a structured plan using this format: + +# Plan: + +## Objective +<brief summary> + +## Steps +- [ ] <step 1> +- [ ] <step 2> +- [ ] … + +## Risks / Unknowns +- <risk 1> + +## Validation +- [ ] <validation step 1> + +After you have finished exploring and thinking, output the plan here in the +chat AND write it to .claude/plan.md using the Write tool. """ return planning_prompt diff --git a/code_puppy/command_line/plan_manager.py b/code_puppy/command_line/plan_manager.py new file mode 100644 index 000000000..2b621d936 --- /dev/null +++ b/code_puppy/command_line/plan_manager.py @@ -0,0 +1,237 @@ +"""Plan management for the /plan command. + +Handles reading, writing, parsing, and executing steps from .claude/plan.md. +""" + +import os +import subprocess +from pathlib import Path +from typing import Optional + + +class PlanManager: + """Manage a persisted plan in .claude/plan.md.""" + + PLAN_DIR = ".claude" + PLAN_FILE = "plan.md" + + def __init__(self, cwd: Optional[str] = None) -> None: + self._cwd = cwd or os.getcwd() + + @property + def plan_path(self) -> Path: + return Path(self._cwd) / self.PLAN_DIR / self.PLAN_FILE + + # ------------------------------------------------------------------ + # I/O + # ------------------------------------------------------------------ + + def exists(self) -> bool: + return self.plan_path.exists() + + def load(self) -> Optional[str]: + if not self.exists(): + return None + return self.plan_path.read_text(encoding="utf-8") + + def save(self, content: str) -> Path: + self.plan_path.parent.mkdir(parents=True, exist_ok=True) + self.plan_path.write_text(content, encoding="utf-8") + return self.plan_path + + def create_template(self, title: str = "Plan") -> str: + """Return a bare-bones template the user can fill in.""" + return f"""# Plan: {title} + +## Summary +<!-- Brief overview of what this plan accomplishes --> + +## Steps +- [ ] Step 1 +- [ ] Step 2 +- [ ] Step 3 + +## Risks / Unknowns +- + +## Validation +- [ ] Final validation step +""" + + # ------------------------------------------------------------------ + # Step parsing + # ------------------------------------------------------------------ + + def get_steps(self) -> list[dict]: + """Parse checklist items from the plan. + + Returns a list of dicts with keys: + index — 1-based step number + text — the checkbox label + done — bool + line — 0-based line number in the original file + """ + content = self.load() + if not content: + return [] + + steps: list[dict] = [] + for i, line in enumerate(content.split("\n")): + stripped = line.strip() + if stripped.startswith("- [ ]"): + steps.append( + { + "index": len(steps) + 1, + "text": stripped[5:].strip(), + "done": False, + "line": i, + } + ) + elif stripped.startswith("- [x]") or stripped.startswith("- [X]"): + steps.append( + { + "index": len(steps) + 1, + "text": stripped[5:].strip(), + "done": True, + "line": i, + } + ) + + return steps + + def mark_step_done(self, step_num: int, done: bool = True) -> bool: + """Mark one step as done (or undone) by 1-based index. + + Returns False if the step number doesn't exist. + """ + content = self.load() + if not content: + return False + + target = None + for s in self.get_steps(): + if s["index"] == step_num: + target = s + break + + if not target: + return False + + lines = content.split("\n") + line = lines[target["line"]] + if done: + line = line.replace("- [ ]", "- [x]", 1) + else: + line = line.replace("- [x]", "- [ ]", 1) + line = line.replace("- [X]", "- [ ]", 1) + lines[target["line"]] = line + + self.save("\n".join(lines)) + return True + + # ------------------------------------------------------------------ + # Editor + # ------------------------------------------------------------------ + + def edit_in_editor(self) -> bool: + """Open the plan file in the user's $EDITOR / $VISUAL. + + Creates a template if the file doesn't exist yet. + Returns True if the editor exited successfully (user saved). + """ + path = self.plan_path + path.parent.mkdir(parents=True, exist_ok=True) + + if not path.exists(): + path.write_text(self.create_template(), encoding="utf-8") + + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano" + try: + result = subprocess.run([editor, str(path)]) + return result.returncode == 0 + except FileNotFoundError: + raise RuntimeError(f"Editor '{editor}' not found. Set $EDITOR or $VISUAL.") + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def show_formatted(self) -> str: + """Return a human-readable representation of the plan.""" + content = self.load() + if not content: + return "No plan file found at .claude/plan.md.\nCreate one with /plan <goal> and then /plan edit, or /plan edit to start a template." + + steps = self.get_steps() + done_count = sum(1 for s in steps if s["done"]) + total = len(steps) + + header = f"📋 Plan ({done_count}/{total} steps complete)\n{'─' * 42}\n\n" + return header + content + + # ------------------------------------------------------------------ + # Execution prompt builders + # ------------------------------------------------------------------ + + def build_run_prompt(self, step_num: Optional[int] = None) -> str: + """Build a prompt to send to the LLM for executing step(s). + + * step_num=None — execute all pending (not-done) steps + * step_num=N — execute that specific step only + """ + content = self.load() + if not content: + raise ValueError( + "No plan found at .claude/plan.md. " + "Create one with /plan <goal> and then /plan edit." + ) + + steps = self.get_steps() + + if step_num is not None: + target = None + for s in steps: + if s["index"] == step_num: + target = s + break + if not target: + raise ValueError(f"Step {step_num} not found in the plan.") + if target["done"]: + raise ValueError(f"Step {step_num} is already marked as done.") + + return ( + f"You are now executing step {step_num} from the plan below.\n" + f"\n" + f"## Full plan (for context)\n" + f"{content}\n" + f"\n" + f"## Step {step_num} to execute\n" + f"{target['text']}\n" + f"\n" + f"Implement this step. Modify files, run commands — do whatever is\n" + f"needed. When done, report what was accomplished and output:\n" + f" PLAN_STEP_DONE: {step_num}\n" + ) + + # Execute all pending steps in order. + pending = [s for s in steps if not s["done"]] + if not pending: + raise ValueError("All steps are already completed. No pending steps.") + + pending_text = "\n".join(f"- [ ] {s['text']}" for s in pending) + step_list = ", ".join(str(s["index"]) for s in pending) + + return ( + f"You are now executing the plan below. Complete ALL pending steps.\n" + f"\n" + f"## Full plan (for context)\n" + f"{content}\n" + f"\n" + f"## Pending steps to execute\n" + f"{pending_text}\n" + f"\n" + f"Execute each step in order. When you finish a step, output:\n" + f" PLAN_STEP_DONE: <step-number>\n" + f"\n" + f"Continue until all steps (steps {step_list}) are done, then sum up what was accomplished.\n" + ) diff --git a/tests/command_line/test_core_commands_full_coverage.py b/tests/command_line/test_core_commands_full_coverage.py index 3d52cdc0d..948cebc95 100644 --- a/tests/command_line/test_core_commands_full_coverage.py +++ b/tests/command_line/test_core_commands_full_coverage.py @@ -641,15 +641,90 @@ def test_plan_with_goal_returns_prompt(self): assert isinstance(result, str) assert "plan-only mode" in result assert "add retry logic" in result + assert ".claude/plan.md" in result - def test_plan_without_goal_emits_usage(self): + def test_plan_without_subcommand_emits_usage(self): from code_puppy.command_line.core_commands import handle_plan_command with patch("code_puppy.command_line.core_commands.emit_error") as mock_error: result = handle_plan_command("/plan") assert result is True - mock_error.assert_called_once_with("Usage: /plan <goal>") + mock_error.assert_called_once_with( + "Usage: /plan <goal> | /plan show | /plan edit | /plan run [N] | /plan mark <N>" + ) + + def test_plan_show_returns_plan(self): + from code_puppy.command_line.core_commands import handle_plan_command + + with ( + patch( + "code_puppy.command_line.core_commands.PlanManager", + ) as mock_pm_cls, + patch("code_puppy.command_line.core_commands.emit_info") as mock_info, + ): + mock_pm = mock_pm_cls.return_value + mock_pm.exists.return_value = True + mock_pm.load.return_value = "# Plan: Test\n## Steps\n- [ ] Step 1" + mock_pm.show_formatted.return_value = "📋 Plan output" + result = handle_plan_command("/plan show") + + assert result is True + assert mock_info.called + + def test_plan_show_empty(self): + from code_puppy.command_line.core_commands import handle_plan_command + + with ( + patch( + "code_puppy.command_line.core_commands.PlanManager", + ) as mock_pm_cls, + patch("code_puppy.command_line.core_commands.emit_info") as mock_info, + ): + mock_pm = mock_pm_cls.return_value + mock_pm.exists.return_value = False + mock_pm.show_formatted.return_value = "No plan file found" + result = handle_plan_command("/plan show") + + assert result is True + assert mock_info.called + + def test_plan_edit_opens_file(self): + from code_puppy.command_line.core_commands import handle_plan_command + + with ( + patch( + "code_puppy.command_line.core_commands.PlanManager", + ) as mock_pm_cls, + patch("code_puppy.command_line.core_commands.emit_success") as mock_success, + patch("code_puppy.command_line.core_commands.emit_info"), + ): + mock_pm = mock_pm_cls.return_value + mock_pm.exists.return_value = True + mock_pm.edit_in_editor.return_value = True + result = handle_plan_command("/plan edit") + + assert result is True + mock_success.assert_called_once_with("Plan saved.") + + def test_plan_mark_toggles_step(self): + from code_puppy.command_line.core_commands import handle_plan_command + + with ( + patch( + "code_puppy.command_line.core_commands.PlanManager", + ) as mock_pm_cls, + patch("code_puppy.command_line.core_commands.emit_success") as mock_success, + ): + mock_pm = mock_pm_cls.return_value + mock_pm.get_steps.return_value = [ + {"index": 1, "text": "Step 1", "done": False, "line": 0} + ] + mock_pm.mark_step_done.return_value = True + result = handle_plan_command("/plan mark 1") + + assert result is True + mock_success.assert_called_once_with("Step 1 marked as done.") class TestHandleGeneratePrDescription: diff --git a/tests/command_line/test_plan_manager.py b/tests/command_line/test_plan_manager.py new file mode 100644 index 000000000..f33e0db43 --- /dev/null +++ b/tests/command_line/test_plan_manager.py @@ -0,0 +1,270 @@ +"""Tests for code_puppy/command_line/plan_manager.py.""" + +from unittest.mock import patch + +import pytest + +from code_puppy.command_line.plan_manager import PlanManager + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_plan(tmp_path): + """Return a PlanManager rooted in a temp directory.""" + return PlanManager(cwd=str(tmp_path)) + + +@pytest.fixture +def plan_with_steps(tmp_plan: PlanManager): + """Populate a plan with a few checklist items.""" + content = """# Plan: Test + +## Objective +Test fixture + +## Steps +- [ ] Set up dependencies +- [ ] Implement the feature +- [x] Write unit tests +- [ ] Manual QA + +## Risks +- Time + +## Validation +- [ ] All tests pass +""" + tmp_plan.save(content) + return tmp_plan + + +# --------------------------------------------------------------------------- +# I/O +# --------------------------------------------------------------------------- + + +class TestPlanManagerIO: + def test_exists_returns_false_when_no_file(self, tmp_plan: PlanManager): + assert not tmp_plan.exists() + + def test_save_and_exists(self, tmp_plan: PlanManager): + tmp_plan.save("# My Plan") + assert tmp_plan.exists() + + def test_load_returns_none_when_no_file(self, tmp_plan: PlanManager): + assert tmp_plan.load() is None + + def test_load_returns_content(self, tmp_plan: PlanManager): + content = "# Plan: Hello" + tmp_plan.save(content) + assert tmp_plan.load() == content + + def test_save_creates_parent_dir(self, tmp_plan: PlanManager): + deep = PlanManager(cwd=str(tmp_plan._cwd)) + deep.save("# Deep") + assert deep.plan_path.exists() + + def test_create_template(self, tmp_plan: PlanManager): + template = tmp_plan.create_template("My Plan") + assert "# Plan: My Plan" in template + assert "- [ ] Step 1" in template + assert "## Validation" in template + + +# --------------------------------------------------------------------------- +# Step parsing +# --------------------------------------------------------------------------- + + +class TestStepParsing: + def test_get_steps_returns_empty_when_no_file(self, tmp_plan: PlanManager): + assert tmp_plan.get_steps() == [] + + def test_get_steps_parses_checkboxes(self, plan_with_steps: PlanManager): + steps = plan_with_steps.get_steps() + assert len(steps) == 5 + assert steps[0]["index"] == 1 + assert steps[0]["text"] == "Set up dependencies" + assert steps[0]["done"] is False + + assert steps[2]["index"] == 3 + assert steps[2]["text"] == "Write unit tests" + assert steps[2]["done"] is True + + def test_get_steps_handles_upper_x(self, tmp_plan: PlanManager): + tmp_plan.save("- [X] Done\n- [ ] Pending") + steps = tmp_plan.get_steps() + assert len(steps) == 2 + assert steps[0]["done"] is True + assert steps[1]["done"] is False + + def test_get_steps_ignores_non_checklist_lines(self, plan_with_steps: PlanManager): + steps = plan_with_steps.get_steps() + for s in steps: + assert s["text"] not in ("## Steps", "## Risks") + + +# --------------------------------------------------------------------------- +# Mark done / undone +# --------------------------------------------------------------------------- + + +class TestMarkStep: + def test_mark_done(self, plan_with_steps: PlanManager): + assert plan_with_steps.mark_step_done(1, done=True) + steps = plan_with_steps.get_steps() + assert steps[0]["done"] is True + + def test_mark_undone(self, plan_with_steps: PlanManager): + assert plan_with_steps.mark_step_done(3, done=False) + steps = plan_with_steps.get_steps() + assert steps[2]["done"] is False + + def test_mark_invalid_step(self, plan_with_steps: PlanManager): + assert not plan_with_steps.mark_step_done(99) + + def test_mark_when_no_file(self, tmp_plan: PlanManager): + assert not tmp_plan.mark_step_done(1) + + +# --------------------------------------------------------------------------- +# Display +# --------------------------------------------------------------------------- + + +class TestShowFormatted: + def test_show_when_no_plan(self, tmp_plan: PlanManager): + output = tmp_plan.show_formatted() + assert "No plan file" in output + assert ".claude/plan.md" in output + + def test_show_includes_counts(self, plan_with_steps: PlanManager): + output = plan_with_steps.show_formatted() + assert "1/5 steps complete" in output + assert "# Plan: Test" in output + + +# --------------------------------------------------------------------------- +# Editor +# --------------------------------------------------------------------------- + + +class TestEditInEditor: + def test_creates_template_when_no_file(self, tmp_plan: PlanManager): + with patch( + "subprocess.run", return_value=type("R", (), {"returncode": 0})() + ) as mock_run: + result = tmp_plan.edit_in_editor() + assert result is True + assert tmp_plan.exists() + assert mock_run.called + + def test_editor_error_returns_false(self, tmp_plan: PlanManager): + tmp_plan.save("# Already there") + with patch("subprocess.run", return_value=type("R", (), {"returncode": 1})()): + result = tmp_plan.edit_in_editor() + assert result is False + + def test_editor_not_found_raises(self, tmp_plan: PlanManager): + tmp_plan.save("# Test") + with patch("subprocess.run", side_effect=FileNotFoundError): + with pytest.raises(RuntimeError, match="Editor"): + tmp_plan.edit_in_editor() + + def test_respects_visual_env(self, tmp_plan: PlanManager, monkeypatch): + monkeypatch.setenv("VISUAL", "vim") + tmp_plan.save("# Test") + with patch( + "subprocess.run", return_value=type("R", (), {"returncode": 0})() + ) as mock_run: + tmp_plan.edit_in_editor() + assert mock_run.call_args[0][0][0] == "vim" + + def test_fallback_to_editor_env(self, tmp_plan: PlanManager, monkeypatch): + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", "code") + tmp_plan.save("# Test") + with patch( + "subprocess.run", return_value=type("R", (), {"returncode": 0})() + ) as mock_run: + tmp_plan.edit_in_editor() + assert mock_run.call_args[0][0][0] == "code" + + +# --------------------------------------------------------------------------- +# Execution prompt builders +# --------------------------------------------------------------------------- + + +class TestBuildRunPrompt: + def test_raises_when_no_plan(self, tmp_plan: PlanManager): + with pytest.raises(ValueError, match="No plan found"): + tmp_plan.build_run_prompt() + + def test_raises_when_invalid_step(self, plan_with_steps: PlanManager): + with pytest.raises(ValueError, match="not found"): + plan_with_steps.build_run_prompt(step_num=99) + + def test_raises_when_step_already_done(self, plan_with_steps: PlanManager): + with pytest.raises(ValueError, match="already marked as done"): + plan_with_steps.build_run_prompt(step_num=3) + + def test_returns_prompt_for_single_step(self, plan_with_steps: PlanManager): + prompt = plan_with_steps.build_run_prompt(step_num=1) + assert "Step 1" in prompt + assert "Set up dependencies" in prompt + assert "PLAN_STEP_DONE: 1" in prompt + + def test_returns_prompt_for_all_pending(self, plan_with_steps: PlanManager): + prompt = plan_with_steps.build_run_prompt() + assert "Set up dependencies" in prompt + assert "Implement the feature" in prompt + assert "Manual QA" in prompt + assert "All tests pass" in prompt # validation step, also pending + # The already-done step appears in the "Full plan (for context)" section + # but NOT in the "Pending steps to execute" section. Verify that. + assert "## Pending steps to execute" in prompt + pending_section = prompt.split("## Pending steps to execute")[1] + assert "Write unit tests" not in pending_section + assert "PLAN_STEP_DONE" in prompt + + def test_raises_when_all_done(self, tmp_plan: PlanManager): + tmp_plan.save("- [x] Only step") + from code_puppy.command_line.plan_manager import PlanManager as PM + + pm = PM(cwd=str(tmp_plan._cwd)) + with pytest.raises(ValueError, match="All steps are already completed"): + pm.build_run_prompt() + + +# --------------------------------------------------------------------------- +# PLAN_STEP_DONE marker processing (integration adjacen +# --------------------------------------------------------------------------- + + +class TestProcessStepMarkers: + """Test the marker-processing logic via its PlanManager dependency.""" + + def test_strips_marker_and_updates_file(self, plan_with_steps: PlanManager): + response = "Did the work.\nPLAN_STEP_DONE: 1\nAll done.\n" + from code_puppy.command_line.plan_manager import PlanManager + + pm = PlanManager(cwd=str(plan_with_steps._cwd)) + # We need a minimal version of the marker processing that updates the file + # so we test the effect through plan_manager methods directly. + pm.save(plan_with_steps.load() + "") + plan_with_steps.mark_step_done(1, done=True) + steps = plan_with_steps.get_steps() + assert steps[0]["done"] is True + + # Also simulate what _process_plan_step_markers does: strip the line + filtered = [ + line for line in response.split("\n") if "PLAN_STEP_DONE" not in line + ] + assert "Did the work." in filtered + assert "All done." in filtered + assert not any("PLAN_STEP_DONE" in line for line in filtered)