misterdev 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,238 @@
1
+ """LLM invocation, routing, caching, and ledger recording."""
2
+
3
+ import contextlib
4
+ import time
5
+ from typing import Optional
6
+
7
+ from misterdev.core.models import Task
8
+ from misterdev.core.execution.project import Project
9
+ from misterdev.llm.client import code_gen_abort_check
10
+ from misterdev.config import get_setting
11
+
12
+ from .helpers import logger, _is_truncated
13
+
14
+
15
+ class LLMMixin:
16
+ def _invoke_llm(self, project: Project, prompt: str, system_prompt: str):
17
+ """Call the LLM, optionally streaming with early abort (config opt-in).
18
+
19
+ Returns (text, aborted). Streaming is enabled via llm.streaming=true. A
20
+ cache hit (llm.cache=true) returns the memoized output without a model
21
+ call; it is still re-applied through the gates downstream.
22
+ """
23
+ cache = getattr(project, "llm_cache", None)
24
+ if cache is not None:
25
+ hit = cache.get(system_prompt, prompt)
26
+ if hit is not None:
27
+ logger.info("LLM cache hit; reusing prior output (no model call).")
28
+ return hit, False
29
+ if get_setting(project.config, "llm", "use_tools") and hasattr(
30
+ project.llm_client, "generate_edits"
31
+ ):
32
+ # Structured edit extraction when the model supports tools; the
33
+ # client renders the result into the canonical fence format and
34
+ # falls back to plain generation for models without tool support.
35
+ resp = project.llm_client.generate_edits(prompt, system_prompt)
36
+ return resp.content, resp.finish_reason == "aborted"
37
+ if get_setting(project.config, "llm", "streaming"):
38
+ resp = project.llm_client.generate_stream(
39
+ prompt, system_prompt, abort_check=code_gen_abort_check
40
+ )
41
+ return resp.content, resp.finish_reason == "aborted"
42
+ return self._invoke_with_continuation(project, prompt, system_prompt), False
43
+
44
+ def _invoke_with_continuation(
45
+ self, project: Project, prompt: str, system_prompt: str
46
+ ) -> str:
47
+ """Plain-path generation that recovers a truncated response.
48
+
49
+ Uses ``generate()`` (which exposes ``finish_reason``) instead of
50
+ ``generate_code()``. When the response finishes normally this returns
51
+ ``.content`` exactly as ``generate_code`` would, so the no-truncation
52
+ path is byte-identical to before. When the model cuts the response off
53
+ at its output-token limit, it issues up to ``orchestrator.max_continuations``
54
+ bounded follow-up calls — each through the same client, so budget/usage
55
+ tracking is never bypassed — asking the model to continue where it
56
+ stopped, and concatenates the raw text before the edit parser sees it.
57
+ The loop always halts: it stops on a normal finish_reason or at the cap.
58
+ """
59
+ resp = project.llm_client.generate(prompt, system_prompt)
60
+ if not _is_truncated(resp.finish_reason):
61
+ return resp.content
62
+
63
+ max_continuations = get_setting(
64
+ project.config, "orchestrator", "max_continuations"
65
+ )
66
+ parts = [resp.content]
67
+ for _ in range(max(0, max_continuations)):
68
+ logger.info(
69
+ "Model response truncated (finish_reason="
70
+ f"{resp.finish_reason!r}); requesting continuation."
71
+ )
72
+ cont_prompt = (
73
+ f"{prompt}\n\n## Continuation\n"
74
+ "Your previous output was cut off. Here is what you produced so "
75
+ "far:\n\n" + "".join(parts) + "\n\nContinue the previous output "
76
+ "exactly where it stopped. Do not repeat any earlier text and do "
77
+ "not re-open code fences; emit only the remaining characters."
78
+ )
79
+ resp = project.llm_client.generate(cont_prompt, system_prompt)
80
+ parts.append(resp.content)
81
+ if not _is_truncated(resp.finish_reason):
82
+ break
83
+ return "".join(parts)
84
+
85
+ def _cache_store(self, project, system_prompt, prompt, output, model) -> None:
86
+ """Memoize a gate-passing output (no-op when caching is disabled)."""
87
+ cache = getattr(project, "llm_cache", None)
88
+ if cache is not None and output:
89
+ try:
90
+ cache.put(
91
+ system_prompt, prompt, output, model=model, timestamp=time.time()
92
+ )
93
+ except OSError as e:
94
+ logger.warning(f"Failed to write LLM cache entry: {e}")
95
+
96
+ def _resolve_model(
97
+ self, project: Project, task: Task, strategy: str
98
+ ) -> Optional[str]:
99
+ """Pick a model for this task from llm.routing/llm.models config.
100
+
101
+ Routes by task complexity first, then strategy. Returns None when no
102
+ routing is configured so the client keeps its default model.
103
+ """
104
+ routing = get_setting(project.config, "llm", "routing")
105
+ models = get_setting(project.config, "llm", "models")
106
+ if not routing or not models:
107
+ return None
108
+ tier = routing.get(task.complexity) or routing.get(strategy)
109
+ if not tier:
110
+ return None
111
+ return models.get(tier) or models.get("default")
112
+
113
+ def _select_model(
114
+ self,
115
+ project: Project,
116
+ task: Task,
117
+ strategy: str,
118
+ attempt: int,
119
+ max_attempts: int,
120
+ ) -> Optional[str]:
121
+ """Resolve the model for this attempt.
122
+
123
+ Prefers the ledger-driven dynamic policy when enabled; otherwise falls
124
+ back to the static complexity/strategy routing. Returns None to keep the
125
+ client's default model.
126
+ """
127
+ selector = getattr(project, "model_selector", None)
128
+ if selector is not None and selector.enabled:
129
+ chosen = selector.select(
130
+ task.category, task.complexity, attempt, max_attempts
131
+ )
132
+ if chosen:
133
+ return chosen
134
+ return self._resolve_model(project, task, strategy)
135
+
136
+ def _invoke_routed(
137
+ self,
138
+ project,
139
+ task,
140
+ prompt: str,
141
+ system_prompt: str,
142
+ routed_model: Optional[str],
143
+ attempt: int,
144
+ track_models: bool,
145
+ ):
146
+ """Invoke the LLM on the routed model, degrading to the default on failure.
147
+
148
+ A routed cheap/free model can be unavailable (no provider permitted under
149
+ the data_collection policy) or rate-limited. Rather than fail the task,
150
+ record the routed model's failure (so the ledger deprioritizes it) and
151
+ retry the same attempt on the client's default model. Returns
152
+ (llm_response, aborted, pending_record).
153
+ """
154
+
155
+ def _cost() -> float:
156
+ return project.llm_client.task_cost(task.id) if track_models else 0.0
157
+
158
+ model_used = routed_model or getattr(project.llm_client, "model", "")
159
+ cost_before = _cost()
160
+ t_before = time.time()
161
+
162
+ with self._reasoning_ctx(project, task):
163
+ if not routed_model:
164
+ resp, aborted = self._invoke_llm(project, prompt, system_prompt)
165
+ else:
166
+ logger.info(
167
+ f"[{task.id}] routing to {routed_model} ({task.complexity})"
168
+ )
169
+ try:
170
+ with project.llm_client.with_model(routed_model):
171
+ resp, aborted = self._invoke_llm(project, prompt, system_prompt)
172
+ except Exception as routed_err:
173
+ logger.warning(
174
+ f"[{task.id}] routed model {routed_model} failed "
175
+ f"({routed_err}); falling back to the default model."
176
+ )
177
+ self._ledger_record(
178
+ project,
179
+ task,
180
+ self._pending(
181
+ routed_model, attempt, cost_before, t_before, False
182
+ ),
183
+ success=False,
184
+ )
185
+ model_used = getattr(project.llm_client, "model", "")
186
+ cost_before = _cost()
187
+ t_before = time.time()
188
+ resp, aborted = self._invoke_llm(project, prompt, system_prompt)
189
+
190
+ pending = self._pending(model_used, attempt, cost_before, t_before, aborted)
191
+ return resp, aborted, pending
192
+
193
+ @staticmethod
194
+ def _pending(model, attempt, cost_before, t_before, aborted) -> dict:
195
+ """Build a per-attempt ledger record (latency measured from t_before)."""
196
+ return {
197
+ "model": model,
198
+ "attempt": attempt,
199
+ "cost_before": cost_before,
200
+ "latency": time.time() - t_before,
201
+ "aborted": aborted,
202
+ }
203
+
204
+ def _reasoning_ctx(self, project, task):
205
+ """Context manager requesting reasoning effort scaled to task complexity.
206
+
207
+ Effort comes from the llm.reasoning_effort map (by complexity); the
208
+ client only acts on it for models that support a reasoning budget. A
209
+ no-op when no effort is configured or the client lacks the hook.
210
+ """
211
+ mapping = get_setting(project.config, "llm", "reasoning_effort") or {}
212
+ effort = mapping.get(getattr(task, "complexity", None))
213
+ hook = getattr(project.llm_client, "with_reasoning_effort", None)
214
+ if effort and hook is not None:
215
+ return hook(effort)
216
+ return contextlib.nullcontext()
217
+
218
+ def _ledger_record(
219
+ self, project, task, pending: Optional[dict], *, success: bool
220
+ ) -> None:
221
+ """Record one attempt's outcome to the model ledger (no-op when off)."""
222
+ selector = getattr(project, "model_selector", None)
223
+ if pending is None or selector is None or not selector.enabled:
224
+ return
225
+ task_cost_fn = getattr(project.llm_client, "task_cost", None)
226
+ if task_cost_fn is None:
227
+ return
228
+ cost = max(0.0, task_cost_fn(task.id) - pending["cost_before"])
229
+ project.model_ledger.record(
230
+ pending["model"],
231
+ task.category,
232
+ task.complexity,
233
+ success=success,
234
+ first_try=pending["attempt"] == 0,
235
+ aborted=pending["aborted"],
236
+ cost=cost,
237
+ latency=pending["latency"],
238
+ )
@@ -0,0 +1,23 @@
1
+ """Terminal task-status transitions (complete/fail)."""
2
+
3
+ from misterdev.core.models import Task, ExecutionResult
4
+ from misterdev.core.execution.project import Project
5
+
6
+
7
+ class ResultsMixin:
8
+ def _complete_task(
9
+ self, project: Project, task: Task, msg: str, logs: str
10
+ ) -> ExecutionResult:
11
+ project.task_manager.update_task_status(task.id, "completed")
12
+ # The task changed the tree; mark the symbol graph stale so the next task
13
+ # rebuilds from the new state instead of a map that predates this edit.
14
+ topo = getattr(project, "topography", None)
15
+ if topo is not None and hasattr(topo, "invalidate"):
16
+ topo.invalidate()
17
+ return ExecutionResult(status="completed", message=msg, logs=logs)
18
+
19
+ def _fail_task(
20
+ self, project: Project, task: Task, msg: str, logs: str = ""
21
+ ) -> ExecutionResult:
22
+ project.task_manager.update_task_status(task.id, "failed")
23
+ return ExecutionResult(status="failed", message=msg, logs=logs)
@@ -0,0 +1,19 @@
1
+ """Built-in tools, registered on import so the registry resolves them.
2
+
3
+ Importing this package registers the built-ins with ``misterdev.plugins.TOOLS``;
4
+ third-party tools add themselves via the ``misterdev.tools`` entry-point group.
5
+ """
6
+
7
+ from misterdev.plugins import TOOLS
8
+ from misterdev.tools.command import CommandTool
9
+ from misterdev.tools.file_io import FileIOTool
10
+ from misterdev.tools.formatter import FormatterTool
11
+ from misterdev.tools.git_tool import GitTool
12
+
13
+ TOOLS.register("command", CommandTool)
14
+ TOOLS.register("test_runner", CommandTool) # alias: a test runner is a command
15
+ TOOLS.register("file_io", FileIOTool)
16
+ TOOLS.register("formatter", FormatterTool)
17
+ TOOLS.register("git", GitTool)
18
+
19
+ __all__ = ["CommandTool", "FileIOTool", "FormatterTool", "GitTool"]
@@ -0,0 +1,14 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+
5
+ class BaseTool(ABC):
6
+ def __init__(self, config: dict):
7
+ self.config = config
8
+ self.name = config.get("name", "Unnamed Tool")
9
+ self.type = config.get("type", "base")
10
+
11
+ @abstractmethod
12
+ def execute(self, project: Any, **kwargs) -> Any:
13
+ """Executes the tool's action."""
14
+ pass
@@ -0,0 +1,75 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Any, Tuple
4
+
5
+ from misterdev.tools.base_tool import BaseTool
6
+ from misterdev.logging_setup import setup_logger
7
+ from misterdev.utils.process import kill_process_group
8
+
9
+ logger = setup_logger(__name__)
10
+
11
+
12
+ class CommandTool(BaseTool):
13
+ def execute(
14
+ self, project: Any, command: str, cwd: str | Path = None, timeout: int = 120
15
+ ) -> Tuple[bool, str]:
16
+ """
17
+ Executes a shell command.
18
+ Returns a tuple of (success_boolean, output_or_error_string).
19
+ """
20
+ work_dir = cwd or project.path
21
+ logger.info(f"Executing command: '{command}' in {work_dir}")
22
+
23
+ try:
24
+ # start_new_session puts the command in its own process group so a
25
+ # timeout can SIGKILL the WHOLE tree, not just the shell — otherwise a
26
+ # backgrounded grandchild (server, compiler) is orphaned and keeps
27
+ # running after the tool returns. errors="replace" keeps a stray
28
+ # non-UTF8 byte from raising and being misreported as a failure.
29
+ proc = subprocess.Popen(
30
+ command,
31
+ shell=True,
32
+ cwd=work_dir,
33
+ stdout=subprocess.PIPE,
34
+ stderr=subprocess.PIPE,
35
+ text=True,
36
+ errors="replace",
37
+ start_new_session=True,
38
+ )
39
+ try:
40
+ stdout, stderr = proc.communicate(timeout=timeout)
41
+ except subprocess.TimeoutExpired:
42
+ kill_process_group(proc)
43
+ # Reap the killed group, but bound the wait: a grandchild that
44
+ # double-forked out of the group (a daemonizing server) keeps the
45
+ # inherited stdout/stderr pipe open, so an unbounded communicate()
46
+ # would hang the tool forever after the timeout already fired.
47
+ try:
48
+ proc.communicate(timeout=5)
49
+ except subprocess.TimeoutExpired:
50
+ pass
51
+ error_msg = f"Command timed out after {timeout}s: {command}"
52
+ logger.error(error_msg)
53
+ return False, error_msg
54
+
55
+ output = stdout
56
+ if stderr:
57
+ output += "\n" + stderr
58
+
59
+ success = proc.returncode == 0
60
+ if not success:
61
+ logger.warning(
62
+ f"Command failed with exit code {proc.returncode}:\n{output}"
63
+ )
64
+
65
+ return success, output
66
+
67
+ except FileNotFoundError:
68
+ error_msg = f"Command not found: {command}"
69
+ logger.error(error_msg)
70
+ return False, error_msg
71
+
72
+ except Exception as e:
73
+ error_msg = f"Failed to execute command: {e}"
74
+ logger.error(error_msg)
75
+ return False, error_msg
@@ -0,0 +1,69 @@
1
+ from typing import Any, Tuple, Optional
2
+ import os
3
+
4
+ from misterdev.tools.base_tool import BaseTool
5
+ from misterdev.utils.file_utils import read_file_capped, write_file
6
+ from misterdev.logging_setup import setup_logger
7
+
8
+ logger = setup_logger(__name__)
9
+
10
+
11
+ class FileIOTool(BaseTool):
12
+ """Tool for explicit file I/O operations."""
13
+
14
+ def execute(
15
+ self, project: Any, action: str, path: str, content: Optional[str] = None
16
+ ) -> Tuple[bool, Any]:
17
+ """
18
+ Executes a file I/O action.
19
+ Actions: read, write, exists, delete
20
+ """
21
+ project_root = project.path.resolve()
22
+ full_path = (project.path / path).resolve()
23
+ if not full_path.is_relative_to(project_root):
24
+ return False, f"Path traversal blocked: {path}"
25
+ # An empty/"."/"./ " path resolves to the project root itself, which passes
26
+ # the traversal guard above. Refuse to operate on the root so a model
27
+ # emitting such a path cannot delete (rmtree) the whole project.
28
+ if full_path == project_root:
29
+ return False, f"Refusing to operate on the project root: {path!r}"
30
+ # The orchestrator's rollback/bisect safety paths depend on version
31
+ # history, so a stray delete of .git would break its own ability to
32
+ # revert bad work. This tool edits project source, never git internals.
33
+ git_dir = project_root / ".git"
34
+ if full_path == git_dir or git_dir in full_path.parents:
35
+ return False, f"Refusing to operate on the git directory: {path!r}"
36
+
37
+ try:
38
+ if action == "read":
39
+ if not full_path.exists():
40
+ return False, f"File not found: {path}"
41
+ return True, read_file_capped(full_path)
42
+
43
+ elif action == "write":
44
+ if content is None:
45
+ return False, "No content provided for write action."
46
+ write_file(full_path, content)
47
+ return True, f"Successfully wrote to {path}"
48
+
49
+ elif action == "exists":
50
+ return True, full_path.exists()
51
+
52
+ elif action == "delete":
53
+ if full_path.exists():
54
+ if full_path.is_file():
55
+ os.remove(full_path)
56
+ else:
57
+ import shutil
58
+
59
+ shutil.rmtree(full_path)
60
+ return True, f"Deleted {path}"
61
+ return True, f"{path} did not exist."
62
+
63
+ else:
64
+ return False, f"Unsupported action: {action}"
65
+
66
+ except Exception as e:
67
+ error_msg = f"File I/O error: {e}"
68
+ logger.error(error_msg)
69
+ return False, error_msg
@@ -0,0 +1,26 @@
1
+ from typing import Any, Tuple
2
+
3
+ from misterdev.tools.command import CommandTool
4
+ from misterdev.logging_setup import setup_logger
5
+
6
+ logger = setup_logger(__name__)
7
+
8
+
9
+ class FormatterTool(CommandTool):
10
+ def execute(self, project: Any, file_path: str = ".") -> Tuple[bool, str]:
11
+ """
12
+ Executes a code formatter.
13
+ """
14
+ command_template = self.config.get("command")
15
+ if not command_template:
16
+ return False, "No command template provided for formatter tool."
17
+
18
+ # Only substitute a path for per-file formatters (templates containing
19
+ # {path}). Project-wide formatters like `cargo fmt` or `ruff format .`
20
+ # have no placeholder and must run as-is, not once per modified file.
21
+ if "{path}" in command_template:
22
+ command = command_template.format(path=file_path)
23
+ else:
24
+ command = command_template
25
+ logger.info(f"Running formatter: {command}")
26
+ return super().execute(project, command=command)
@@ -0,0 +1,90 @@
1
+ import shlex
2
+ from typing import Any, Tuple
3
+
4
+ from misterdev.tools.command import CommandTool
5
+ from misterdev.logging_setup import setup_logger
6
+
7
+ logger = setup_logger(__name__)
8
+
9
+
10
+ class GitTool(CommandTool):
11
+ """Tool for common Git operations including worktrees and branching."""
12
+
13
+ def execute(self, project: Any, action: str, **kwargs) -> Tuple[bool, str]:
14
+ if action == "status":
15
+ return self.status(project)
16
+ elif action == "add":
17
+ return self.add(project, kwargs.get("files", "."))
18
+ elif action == "commit":
19
+ return self.commit(
20
+ project, kwargs.get("message", "Auto-commit by Project Orchestrator")
21
+ )
22
+ elif action == "diff":
23
+ return self.diff(project)
24
+ elif action == "worktree_add":
25
+ return self.worktree_add(project, kwargs.get("path"), kwargs.get("branch"))
26
+ elif action == "worktree_remove":
27
+ return self.worktree_remove(project, kwargs.get("path"))
28
+ elif action == "branch_create":
29
+ return self.branch_create(project, kwargs.get("branch"))
30
+ elif action == "branch_delete":
31
+ return self.branch_delete(project, kwargs.get("branch"))
32
+ elif action == "merge":
33
+ return self.merge(project, kwargs.get("branch"))
34
+ elif action == "checkout":
35
+ return self.checkout(project, kwargs.get("branch"))
36
+ else:
37
+ return super().execute(project, command=f"git {shlex.quote(action)}")
38
+
39
+ def worktree_add(
40
+ self, project: Any, path: str, branch: str, new_branch: bool = False
41
+ ) -> Tuple[bool, str]:
42
+ logger.info(
43
+ f"Creating git worktree at {path} (branch: {branch}, new={new_branch})"
44
+ )
45
+ flag = "-b " if new_branch else ""
46
+ return super().execute(
47
+ project,
48
+ command=f"git worktree add {shlex.quote(path)} {flag}{shlex.quote(branch)}",
49
+ )
50
+
51
+ def worktree_remove(self, project: Any, path: str) -> Tuple[bool, str]:
52
+ logger.info(f"Removing git worktree at {path}")
53
+ return super().execute(
54
+ project, command=f"git worktree remove --force {shlex.quote(path)}"
55
+ )
56
+
57
+ def merge_worktree(self, project: Any, branch: str) -> Tuple[bool, str]:
58
+ """Merge a worktree's branch into the current branch, then delete it."""
59
+ success, out = super().execute(
60
+ project, command=f"git merge --no-ff {shlex.quote(branch)} --no-edit"
61
+ )
62
+ if success:
63
+ super().execute(project, command=f"git branch -d {shlex.quote(branch)}")
64
+ return success, out
65
+
66
+ def branch_create(self, project: Any, branch: str) -> Tuple[bool, str]:
67
+ return super().execute(
68
+ project, command=f"git checkout -b {shlex.quote(branch)}"
69
+ )
70
+
71
+ def branch_delete(self, project: Any, branch: str) -> Tuple[bool, str]:
72
+ return super().execute(project, command=f"git branch -D {shlex.quote(branch)}")
73
+
74
+ def merge(self, project: Any, branch: str) -> Tuple[bool, str]:
75
+ return super().execute(project, command=f"git merge {shlex.quote(branch)}")
76
+
77
+ def checkout(self, project: Any, branch: str) -> Tuple[bool, str]:
78
+ return super().execute(project, command=f"git checkout {shlex.quote(branch)}")
79
+
80
+ def status(self, project: Any) -> Tuple[bool, str]:
81
+ return super().execute(project, command="git status")
82
+
83
+ def add(self, project: Any, files: str) -> Tuple[bool, str]:
84
+ return super().execute(project, command=f"git add {shlex.quote(files)}")
85
+
86
+ def commit(self, project: Any, message: str) -> Tuple[bool, str]:
87
+ return super().execute(project, command=f"git commit -m {shlex.quote(message)}")
88
+
89
+ def diff(self, project: Any) -> Tuple[bool, str]:
90
+ return super().execute(project, command="git diff")
File without changes