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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- 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
|