Skip to content

Commit ee23780

Browse files
feat(cli): phase 18 - reintegrate patch proposal engine
1 parent 70dd457 commit ee23780

4 files changed

Lines changed: 104 additions & 15 deletions

File tree

mythic_vibe_cli/app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,14 @@ def _help(text: str) -> str:
941941
help="Baseline git ref (e.g. v1.2.3 or a sha) to compare against HEAD",
942942
)
943943
add_runtime_options(rollback, json_output=True)
944+
# Phase 18: Reintegrate Patch Proposal Engine
945+
patch = sub.add_parser("patch", help=_help("Manage patch proposals"))
946+
patch_sub = patch.add_subparsers(dest="patch_command", required=True)
947+
patch_propose = patch_sub.add_parser("propose", help=_help("Propose a safe file patch"))
948+
patch_propose.add_argument("--file", required=True, help="Target file to patch")
949+
patch_propose.add_argument("--content", required=True, help="Proposed file content")
950+
patch_propose.add_argument("--path", default=".", help="Project directory (default: current directory)")
951+
add_runtime_options(patch_propose, json_output=True)
944952

945953
# PH-11 Slice 11.7: `mythic-vibe security audit`.
946954
security = sub.add_parser(

mythic_vibe_cli/commands.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4390,6 +4390,42 @@ def cmd_docker_dispatch(args: argparse.Namespace) -> int:
43904390
return USER_INPUT_ERROR
43914391

43924392

4393+
def cmd_patch_propose(args: argparse.Namespace) -> int:
4394+
from .patch.manager import PatchManager
4395+
4396+
root = Path(getattr(args, "path", ".")).resolve()
4397+
target_file = getattr(args, "file", "")
4398+
content = getattr(args, "content", "")
4399+
4400+
if not target_file or not content:
4401+
write_error("--file and --content are required.")
4402+
return USER_INPUT_ERROR
4403+
4404+
pm = PatchManager(project_root=root)
4405+
proposal = pm.propose(target_file, content)
4406+
4407+
payload = {
4408+
"command": "patch propose",
4409+
"target_file": proposal.target_file,
4410+
"status": "staged",
4411+
}
4412+
4413+
if _flag(args, "json"):
4414+
write_json(payload)
4415+
return SUCCESS
4416+
4417+
write_line(f"Patch proposed for {proposal.target_file}. Use `/patch apply` in the companion shell to apply.")
4418+
return SUCCESS
4419+
4420+
4421+
def cmd_patch_dispatch(args: argparse.Namespace) -> int:
4422+
sub = getattr(args, "patch_command", "")
4423+
if sub == "propose":
4424+
return cmd_patch_propose(args)
4425+
write_error(f"Unknown patch subcommand: {sub!r}")
4426+
return USER_INPUT_ERROR
4427+
4428+
43934429
def cmd_release(args: argparse.Namespace) -> int:
43944430
"""PH-12 Slice 12.3 — semver-aware release helper.
43954431
@@ -7756,6 +7792,7 @@ def cmd_drift(args: argparse.Namespace) -> int:
77567792
"security": cmd_security_dispatch,
77577793
"ci": cmd_ci_dispatch,
77587794
"docker": cmd_docker_dispatch,
7795+
"patch": cmd_patch_dispatch,
77597796
"release": cmd_release,
77607797
"rollback": cmd_rollback,
77617798
"policy": cmd_policy_dispatch,

mythic_vibe_cli/patch/manager.py

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import json
1011
import difflib
1112
from dataclasses import dataclass
1213
from pathlib import Path
@@ -32,16 +33,56 @@ def generate_diff(self) -> str:
3233
)
3334
return "".join(diff)
3435

36+
def to_dict(self) -> dict[str, str]:
37+
return {
38+
"target_file": self.target_file,
39+
"original_content": self.original_content,
40+
"proposed_content": self.proposed_content,
41+
}
42+
43+
@classmethod
44+
def from_dict(cls, data: dict[str, str]) -> "PatchProposal":
45+
return cls(
46+
target_file=data["target_file"],
47+
original_content=data["original_content"],
48+
proposed_content=data["proposed_content"],
49+
)
50+
3551

3652
class PatchManager:
37-
"""Manages the active patch proposal in the current session."""
53+
"""Manages the active patch proposal across the session via file persistence."""
54+
55+
def __init__(self, project_root: str | Path | None = None) -> None:
56+
if project_root is None:
57+
self.project_root = Path.cwd()
58+
else:
59+
self.project_root = Path(project_root).resolve()
60+
61+
self.state_file = self.project_root / ".mythic" / "active_patch.json"
62+
63+
def _read_active(self) -> PatchProposal | None:
64+
if not self.state_file.exists():
65+
return None
66+
try:
67+
data = json.loads(self.state_file.read_text(encoding="utf-8"))
68+
return PatchProposal.from_dict(data)
69+
except Exception:
70+
return None
3871

39-
def __init__(self) -> None:
40-
self._active_patch: PatchProposal | None = None
72+
def _write_active(self, proposal: PatchProposal | None) -> None:
73+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
74+
if proposal is None:
75+
if self.state_file.exists():
76+
self.state_file.unlink()
77+
else:
78+
self.state_file.write_text(json.dumps(proposal.to_dict(), indent=2), encoding="utf-8")
4179

4280
def propose(self, target_file: str | Path, proposed_content: str) -> PatchProposal:
4381
"""Stages a patch for review."""
44-
path = Path(target_file).resolve()
82+
path = Path(target_file)
83+
if not path.is_absolute():
84+
path = self.project_root / path
85+
path = path.resolve()
4586

4687
try:
4788
original_content = path.read_text(encoding="utf-8") if path.exists() else ""
@@ -53,36 +94,38 @@ def propose(self, target_file: str | Path, proposed_content: str) -> PatchPropos
5394
original_content=original_content,
5495
proposed_content=proposed_content,
5596
)
56-
self._active_patch = proposal
97+
self._write_active(proposal)
5798
return proposal
5899

59100
def get_active(self) -> PatchProposal | None:
60101
"""Returns the currently active patch proposal, if any."""
61-
return self._active_patch
102+
return self._read_active()
62103

63104
def apply_active(self) -> bool:
64105
"""Applies the active patch to the file system and clears it."""
65-
if not self._active_patch:
106+
active = self._read_active()
107+
if not active:
66108
return False
67109

68-
path = Path(self._active_patch.target_file)
110+
path = Path(active.target_file)
69111
path.parent.mkdir(parents=True, exist_ok=True)
70-
path.write_text(self._active_patch.proposed_content, encoding="utf-8")
71-
self._active_patch = None
112+
path.write_text(active.proposed_content, encoding="utf-8")
113+
self._write_active(None)
72114
return True
73115

74116
def reject_active(self) -> bool:
75117
"""Rejects the active patch and clears it."""
76-
if not self._active_patch:
118+
if not self._read_active():
77119
return False
78120

79-
self._active_patch = None
121+
self._write_active(None)
80122
return True
81123

82124
def get_diff(self) -> str:
83125
"""Returns the unified diff of the active patch."""
84-
if not self._active_patch:
126+
active = self._read_active()
127+
if not active:
85128
return "No active patch proposal."
86-
return self._active_patch.generate_diff()
129+
return active.generate_diff()
87130

88131
__all__ = ["PatchProposal", "PatchManager"]

mythic_vibe_cli/repl.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,11 @@ def _known_command_names(_main: Callable[[list[str]], int]) -> set[str]:
364364
2. INSPECT the repository using file and directory search tools if context is missing.
365365
3. RETRIEVE memory or knowledge if asked.
366366
4. PROPOSE a structured plan using tools if the task is complex.
367-
5. EDIT code, run tests, and execute commands to achieve the goal.
367+
5. EDIT code using the `mythic_vibe_patch` tool with argv `["propose", "--file", "...", "--content", "..."]`. The user will review and apply the patch.
368368
6. REMEMBER the results of your actions.
369369
370370
Workspace Safety Protocol:
371+
- Always use the `mythic_vibe_patch` tool to propose code edits. Do NOT expect the user to manually edit files unless specifically requested.
371372
- Always use `workspace diff` to review your own changes before making a commit.
372373
- You must ask the user for explicit confirmation before executing `workspace commit` or `workspace push`.
373374

0 commit comments

Comments
 (0)