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,283 @@
|
|
|
1
|
+
"""Governance layer: risk classification + approval gates (opt-in).
|
|
2
|
+
|
|
3
|
+
Classifies a shell command (or a described action) as *risky* when it matches a
|
|
4
|
+
destructive, irreversible, or paid operation — ``rm -rf``, ``git push --force``,
|
|
5
|
+
``DROP TABLE``/``DROP DATABASE``, ``kubectl delete``, ``terraform apply``/
|
|
6
|
+
``destroy``, cloud-CLI ``delete``/``rm``, ``docker rmi``/``system prune``,
|
|
7
|
+
deploy/publish commands, and ``curl ... | sh`` pipe-to-shell. Ordinary build,
|
|
8
|
+
test, and lint commands (``cargo test``, ``pytest -q``, ``npm run build``,
|
|
9
|
+
``ruff check``, ``true``/``false``) classify as SAFE.
|
|
10
|
+
|
|
11
|
+
Design mirrors :mod:`misterdev.core.context.lsp` / ``container`` / ``mcp``:
|
|
12
|
+
strictly opt-in (``orchestrator.governance`` is off by default), never raises
|
|
13
|
+
into the caller, and a no-op when off so behavior is byte-identical to today.
|
|
14
|
+
|
|
15
|
+
When ON, :class:`GovernancePolicy.authorize` is consulted by the command seam:
|
|
16
|
+
- SAFE actions are always authorized.
|
|
17
|
+
- A risky action in *autonomous* (non-interactive) mode is REFUSED unless
|
|
18
|
+
``governance.auto_approve`` is set, and an escalation is recorded.
|
|
19
|
+
- A risky action in *interactive* mode prompts the operator (the prompt callable
|
|
20
|
+
is injected so tests stay deterministic and the loop never hangs on stdin).
|
|
21
|
+
|
|
22
|
+
The classifier is intentionally precise: a false positive on a normal build
|
|
23
|
+
command would block real builds, so patterns are anchored to the destructive
|
|
24
|
+
*verb* (e.g. ``rm`` with a recursive/force flag, ``delete``/``destroy``
|
|
25
|
+
subcommands of known tools), never to incidental substrings.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Callable, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from misterdev.logging_setup import setup_logger
|
|
33
|
+
|
|
34
|
+
logger = setup_logger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Each entry: (compiled regex, human reason). Patterns match against the raw
|
|
38
|
+
# command string. They are anchored to a destructive verb/flag combination so
|
|
39
|
+
# ordinary build/test/lint commands cannot trip them. `re.IGNORECASE` is applied
|
|
40
|
+
# uniformly; SQL keywords and CLI verbs are case-insensitive in practice.
|
|
41
|
+
_RISK_RULES: Tuple[Tuple[str, str], ...] = (
|
|
42
|
+
# rm with a recursive OR force flag (combined or split): rm -rf, rm -r -f,
|
|
43
|
+
# rm --recursive, rm -fr. Plain `rm file` is NOT flagged (recoverable enough,
|
|
44
|
+
# and far too common to gate). The flag cluster must contain r or f.
|
|
45
|
+
(
|
|
46
|
+
r"\brm\s+(?:-[a-z]*[rf][a-z]*\b|--recursive\b|--force\b)",
|
|
47
|
+
"recursive/forced file removal (rm -rf)",
|
|
48
|
+
),
|
|
49
|
+
# git push --force / -f / --force-with-lease (history rewrite is irreversible
|
|
50
|
+
# on the remote). A plain `git push` is gated separately below as a publish.
|
|
51
|
+
(
|
|
52
|
+
r"\bgit\s+push\b.*(?:--force\b|--force-with-lease\b|\s-f\b)",
|
|
53
|
+
"force push rewrites remote history",
|
|
54
|
+
),
|
|
55
|
+
# plain git push (publishes to a remote; reversible but external side effect).
|
|
56
|
+
(r"\bgit\s+push\b", "git push publishes to a remote"),
|
|
57
|
+
# SQL destructive DDL: DROP TABLE / DROP DATABASE / DROP SCHEMA, TRUNCATE.
|
|
58
|
+
(r"\bdrop\s+(?:table|database|schema)\b", "SQL DROP destroys schema/data"),
|
|
59
|
+
(r"\btruncate\s+table\b", "SQL TRUNCATE deletes all rows"),
|
|
60
|
+
# kubectl delete (any resource).
|
|
61
|
+
(r"\bkubectl\s+delete\b", "kubectl delete removes cluster resources"),
|
|
62
|
+
# terraform apply / destroy (provisions or tears down real infrastructure).
|
|
63
|
+
(
|
|
64
|
+
r"\bterraform\s+(?:apply|destroy)\b",
|
|
65
|
+
"terraform apply/destroy mutates infrastructure",
|
|
66
|
+
),
|
|
67
|
+
# Cloud CLIs with a delete/rm subcommand: aws ... rm/delete, gcloud ...
|
|
68
|
+
# delete, az ... delete. Anchored to the CLI name + a delete-family verb so
|
|
69
|
+
# read-only cloud commands (list/describe/get) are SAFE.
|
|
70
|
+
(
|
|
71
|
+
r"\baws\b[^|;&]*\b(?:rm|delete|delete-[a-z-]+|terminate-[a-z-]+)\b",
|
|
72
|
+
"aws delete/terminate is destructive",
|
|
73
|
+
),
|
|
74
|
+
(r"\bgcloud\b[^|;&]*\bdelete\b", "gcloud delete is destructive"),
|
|
75
|
+
(r"\baz\b[^|;&]*\bdelete\b", "az delete is destructive"),
|
|
76
|
+
# docker/podman image removal and prune (reclaims/destroys images/volumes).
|
|
77
|
+
(r"\b(?:docker|podman)\s+rmi\b", "container image removal (rmi)"),
|
|
78
|
+
(
|
|
79
|
+
r"\b(?:docker|podman)\s+system\s+prune\b",
|
|
80
|
+
"docker system prune deletes unused data",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
r"\b(?:docker|podman)\s+volume\s+(?:rm|prune)\b",
|
|
84
|
+
"container volume removal/prune",
|
|
85
|
+
),
|
|
86
|
+
# Package publish (paid/irreversible release to a public registry).
|
|
87
|
+
(r"\bnpm\s+publish\b", "npm publish releases a package"),
|
|
88
|
+
(r"\b(?:cargo|yarn|pnpm)\s+publish\b", "package publish releases to a registry"),
|
|
89
|
+
(r"\btwine\s+upload\b", "twine upload publishes to PyPI"),
|
|
90
|
+
(r"\bpip\s+upload\b", "pip upload publishes a package"),
|
|
91
|
+
(r"\bgh\s+release\s+create\b", "gh release create publishes a release"),
|
|
92
|
+
# Deploy verbs of common tools (external side effect, often paid).
|
|
93
|
+
(
|
|
94
|
+
r"\b(?:vercel|netlify|fly|flyctl|wrangler|heroku)\s+deploy\b",
|
|
95
|
+
"deploy command pushes to a hosting provider",
|
|
96
|
+
),
|
|
97
|
+
(r"\bserverless\s+deploy\b", "serverless deploy provisions cloud resources"),
|
|
98
|
+
(r"\bkubectl\s+apply\b", "kubectl apply mutates cluster state"),
|
|
99
|
+
# curl/wget piped directly into a shell (executes untrusted remote code).
|
|
100
|
+
(
|
|
101
|
+
r"\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b",
|
|
102
|
+
"pipe-to-shell executes untrusted remote code",
|
|
103
|
+
),
|
|
104
|
+
# dd to a block device (overwrites disks).
|
|
105
|
+
(r"\bdd\b[^|;&]*\bof=/dev/", "dd to a device overwrites a disk"),
|
|
106
|
+
# mkfs / fdisk (formats/partitions a disk).
|
|
107
|
+
(r"\bmkfs\b", "mkfs formats a filesystem"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
_COMPILED_RULES: Tuple[Tuple[re.Pattern, str], ...] = tuple(
|
|
111
|
+
(re.compile(pat, re.IGNORECASE), reason) for pat, reason in _RISK_RULES
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Longest command slice matched against user-supplied approval_required patterns;
|
|
115
|
+
# bounds worst-case regex backtracking (see is_risky).
|
|
116
|
+
_MAX_PATTERN_INPUT = 4096
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_risky(
|
|
120
|
+
command: str, extra_patterns: Optional[List[str]] = None
|
|
121
|
+
) -> Tuple[bool, str]:
|
|
122
|
+
"""Classify ``command`` as risky (destructive/irreversible/paid) or SAFE.
|
|
123
|
+
|
|
124
|
+
Returns ``(True, reason)`` for a risky command, ``(False, "")`` otherwise.
|
|
125
|
+
Never raises: a malformed extra pattern is logged and skipped so a bad policy
|
|
126
|
+
entry can never break the build loop. ``extra_patterns`` are project-supplied
|
|
127
|
+
additional risky regexes (``governance.approval_required``).
|
|
128
|
+
"""
|
|
129
|
+
if not command or not isinstance(command, str):
|
|
130
|
+
return False, ""
|
|
131
|
+
for rule, reason in _COMPILED_RULES:
|
|
132
|
+
if rule.search(command):
|
|
133
|
+
return True, reason
|
|
134
|
+
# Bound the input a user-supplied pattern matches against: an adversarial
|
|
135
|
+
# (or accidentally pathological) approval_required regex can backtrack
|
|
136
|
+
# catastrophically, and its cost grows with input length. Real commands are
|
|
137
|
+
# short, so capping the matched slice removes the ReDoS blowup without
|
|
138
|
+
# affecting classification of legitimate commands.
|
|
139
|
+
probe = command[:_MAX_PATTERN_INPUT]
|
|
140
|
+
for raw in extra_patterns or []:
|
|
141
|
+
try:
|
|
142
|
+
if re.search(raw, probe, re.IGNORECASE):
|
|
143
|
+
return True, f"matches policy pattern: {raw}"
|
|
144
|
+
except re.error as e:
|
|
145
|
+
logger.warning(f"Ignoring invalid governance pattern {raw!r}: {e}")
|
|
146
|
+
return False, ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class GovernanceDecision:
|
|
151
|
+
"""Outcome of an authorization check."""
|
|
152
|
+
|
|
153
|
+
allowed: bool
|
|
154
|
+
risky: bool
|
|
155
|
+
reason: str = ""
|
|
156
|
+
escalated: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class GovernancePolicy:
|
|
161
|
+
"""Approval policy consulted by the command seam when governance is ON.
|
|
162
|
+
|
|
163
|
+
``enabled`` mirrors ``orchestrator.governance``; when False the policy is a
|
|
164
|
+
transparent no-op (everything authorized, nothing recorded), so default-off
|
|
165
|
+
behavior is identical to today. ``interactive`` selects prompt-vs-block for a
|
|
166
|
+
risky action. ``prompt`` is injected (defaults to a non-interactive denier)
|
|
167
|
+
so the loop never blocks on stdin in tests/autonomous runs.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
enabled: bool = False
|
|
171
|
+
interactive: bool = False
|
|
172
|
+
auto_approve: bool = False
|
|
173
|
+
approval_required: List[str] = field(default_factory=list)
|
|
174
|
+
audit: Optional["object"] = None # AuditTrail-like; duck-typed to avoid a cycle
|
|
175
|
+
prompt: Optional[Callable[[str, str], bool]] = None
|
|
176
|
+
escalations: List[dict] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
def classify(self, command: str) -> Tuple[bool, str]:
|
|
179
|
+
return is_risky(command, self.approval_required)
|
|
180
|
+
|
|
181
|
+
def authorize(self, command: str, action: str = "command") -> GovernanceDecision:
|
|
182
|
+
"""Decide whether ``command`` may run. SAFE always allowed; risky depends
|
|
183
|
+
on mode + auto_approve. Records an escalation + audit entry on refusal.
|
|
184
|
+
|
|
185
|
+
Never raises: any audit/prompt failure degrades to a recorded decision.
|
|
186
|
+
"""
|
|
187
|
+
if not self.enabled:
|
|
188
|
+
return GovernanceDecision(allowed=True, risky=False)
|
|
189
|
+
|
|
190
|
+
risky, reason = self.classify(command)
|
|
191
|
+
if not risky:
|
|
192
|
+
return GovernanceDecision(allowed=True, risky=False)
|
|
193
|
+
|
|
194
|
+
if self.auto_approve:
|
|
195
|
+
self._record("gate", command, action, reason, allowed=True, escalated=False)
|
|
196
|
+
return GovernanceDecision(
|
|
197
|
+
allowed=True, risky=True, reason=reason, escalated=False
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if self.interactive and self.prompt is not None:
|
|
201
|
+
try:
|
|
202
|
+
approved = bool(self.prompt(command, reason))
|
|
203
|
+
except Exception as e: # prompt must never break the loop
|
|
204
|
+
logger.warning(f"Governance prompt failed, refusing: {e}")
|
|
205
|
+
approved = False
|
|
206
|
+
self._record(
|
|
207
|
+
"gate",
|
|
208
|
+
command,
|
|
209
|
+
action,
|
|
210
|
+
reason,
|
|
211
|
+
allowed=approved,
|
|
212
|
+
escalated=not approved,
|
|
213
|
+
)
|
|
214
|
+
return GovernanceDecision(
|
|
215
|
+
allowed=approved,
|
|
216
|
+
risky=True,
|
|
217
|
+
reason=reason,
|
|
218
|
+
escalated=not approved,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Autonomous (non-interactive) + no auto_approve: block and escalate.
|
|
222
|
+
self._record("gate", command, action, reason, allowed=False, escalated=True)
|
|
223
|
+
return GovernanceDecision(
|
|
224
|
+
allowed=False, risky=True, reason=reason, escalated=True
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _record(
|
|
228
|
+
self,
|
|
229
|
+
kind: str,
|
|
230
|
+
command: str,
|
|
231
|
+
action: str,
|
|
232
|
+
reason: str,
|
|
233
|
+
allowed: bool,
|
|
234
|
+
escalated: bool,
|
|
235
|
+
) -> None:
|
|
236
|
+
if escalated:
|
|
237
|
+
self.escalations.append(
|
|
238
|
+
{"command": command, "action": action, "reason": reason}
|
|
239
|
+
)
|
|
240
|
+
if self.audit is not None:
|
|
241
|
+
try:
|
|
242
|
+
self.audit.record(
|
|
243
|
+
"gate",
|
|
244
|
+
action=action,
|
|
245
|
+
command=command,
|
|
246
|
+
reason=reason,
|
|
247
|
+
allowed=allowed,
|
|
248
|
+
escalated=escalated,
|
|
249
|
+
)
|
|
250
|
+
except Exception as e: # audit must never break execution
|
|
251
|
+
logger.debug(f"Governance audit record failed: {e}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def policy_from_config(
|
|
255
|
+
config: dict,
|
|
256
|
+
interactive: bool = False,
|
|
257
|
+
audit: Optional[object] = None,
|
|
258
|
+
prompt: Optional[Callable[[str, str], bool]] = None,
|
|
259
|
+
) -> GovernancePolicy:
|
|
260
|
+
"""Build a :class:`GovernancePolicy` from a merged config dict.
|
|
261
|
+
|
|
262
|
+
Reads ``orchestrator.governance`` (bool, default False) and the top-level
|
|
263
|
+
``governance`` section (``approval_required`` list, ``auto_approve`` bool).
|
|
264
|
+
Never raises on a malformed section; missing/odd values degrade to defaults.
|
|
265
|
+
"""
|
|
266
|
+
from misterdev.config import get_setting
|
|
267
|
+
|
|
268
|
+
enabled = bool(get_setting(config, "orchestrator", "governance"))
|
|
269
|
+
gov = config.get("governance") or {}
|
|
270
|
+
if not isinstance(gov, dict):
|
|
271
|
+
gov = {}
|
|
272
|
+
approval_required = gov.get("approval_required") or []
|
|
273
|
+
if not isinstance(approval_required, list):
|
|
274
|
+
approval_required = []
|
|
275
|
+
auto_approve = bool(gov.get("auto_approve", False))
|
|
276
|
+
return GovernancePolicy(
|
|
277
|
+
enabled=enabled,
|
|
278
|
+
interactive=interactive,
|
|
279
|
+
auto_approve=auto_approve,
|
|
280
|
+
approval_required=[str(p) for p in approval_required],
|
|
281
|
+
audit=audit,
|
|
282
|
+
prompt=prompt,
|
|
283
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared result type for the optional, non-blocking gates.
|
|
2
|
+
|
|
3
|
+
The runtime smoke, web, vision, and mutation gates all report the same tri-state
|
|
4
|
+
outcome — SKIP / GREEN / RED — with the same ``passed`` and ``skipped`` meaning,
|
|
5
|
+
plus a ``reason`` and gate-specific evidence. This centralizes the status
|
|
6
|
+
vocabulary and the small boilerplate so each gate's result class only declares
|
|
7
|
+
its own evidence fields.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Tri-state gate status. GREEN passed; RED failed (blocks the build); SKIP is
|
|
11
|
+
# "no opinion" (missing config/dep, unparseable, or timeout) and never blocks.
|
|
12
|
+
SKIP = "skip"
|
|
13
|
+
GREEN = "green"
|
|
14
|
+
RED = "red"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GateOutcome:
|
|
18
|
+
"""Base for a gate result: a SKIP/GREEN/RED ``status`` and a ``reason``.
|
|
19
|
+
|
|
20
|
+
Subclasses add their own evidence fields and call ``super().__init__``.
|
|
21
|
+
``passed`` is GREEN; ``skipped`` is SKIP.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status: str, reason: str = ""):
|
|
25
|
+
self.status = status
|
|
26
|
+
self.reason = reason
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def passed(self) -> bool:
|
|
30
|
+
return self.status == GREEN
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def skipped(self) -> bool:
|
|
34
|
+
return self.status == SKIP
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GateContext:
|
|
38
|
+
"""Inputs a registered plugin gate receives.
|
|
39
|
+
|
|
40
|
+
``project_path`` is the repo (or target subdir) root; ``commands`` are the
|
|
41
|
+
resolved build/test/lint/typecheck commands; ``env_activate`` is the optional
|
|
42
|
+
host-venv activation prefix. A plugin gate is ``callable(GateContext) ->
|
|
43
|
+
GateOutcome`` registered on ``misterdev.plugins.GATES``; a RED outcome blocks
|
|
44
|
+
the build, SKIP/GREEN do not.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, project_path, commands, env_activate=None):
|
|
48
|
+
self.project_path = project_path
|
|
49
|
+
self.commands = commands
|
|
50
|
+
self.env_activate = env_activate
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Task progress persistence for crash recovery.
|
|
2
|
+
|
|
3
|
+
Saves completed and failed task IDs to disk after each task finishes.
|
|
4
|
+
On build start, checks for existing progress and resumes from where
|
|
5
|
+
the previous run left off.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, Optional, Set
|
|
13
|
+
|
|
14
|
+
from misterdev.logging_setup import setup_logger
|
|
15
|
+
from misterdev.utils.file_utils import (
|
|
16
|
+
atomic_write,
|
|
17
|
+
orchestrator_state_file,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_task_hash(task, project_path: Path) -> str:
|
|
24
|
+
"""Content hash of a task's inputs: its devplan file + target file mtimes.
|
|
25
|
+
|
|
26
|
+
Used to detect when a previously-completed task needs re-running because
|
|
27
|
+
its spec or the files it touches changed since it last succeeded.
|
|
28
|
+
"""
|
|
29
|
+
h = hashlib.sha256()
|
|
30
|
+
source_ref = getattr(task, "source_ref", None)
|
|
31
|
+
if source_ref:
|
|
32
|
+
try:
|
|
33
|
+
h.update(Path(source_ref).read_bytes())
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
# Fold in the task's own spec so that LLM-decomposed tasks (which have no
|
|
37
|
+
# source_ref and reuse generic ids like T-001 across separate builds) get a
|
|
38
|
+
# hash that reflects their actual content. Without this, a fresh plan's
|
|
39
|
+
# T-001 collides with a prior build's completed T-001 and is wrongly skipped.
|
|
40
|
+
for attr in ("title", "description"):
|
|
41
|
+
h.update(str(getattr(task, attr, "")).encode())
|
|
42
|
+
for f in sorted(getattr(task, "files_to_create", [])):
|
|
43
|
+
h.update(f"create:{f}".encode())
|
|
44
|
+
for f in sorted(getattr(task, "files_to_modify", [])):
|
|
45
|
+
h.update(f"modify:{f}".encode())
|
|
46
|
+
fp = Path(project_path) / f
|
|
47
|
+
if fp.exists():
|
|
48
|
+
h.update(f"{f}:{fp.stat().st_mtime_ns}".encode())
|
|
49
|
+
return h.hexdigest()[:16]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProgressTracker:
|
|
53
|
+
"""Persists build progress so a crash at task 18 resumes from 18, not 1."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, project_path: Path):
|
|
56
|
+
self._file = orchestrator_state_file(project_path, "progress.json")
|
|
57
|
+
self.completed: Set[str] = set()
|
|
58
|
+
self.failed: Set[str] = set()
|
|
59
|
+
self.hashes: Dict[str, str] = {}
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._load()
|
|
62
|
+
|
|
63
|
+
def _load(self):
|
|
64
|
+
if self._file.exists():
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(self._file.read_text(encoding="utf-8"))
|
|
67
|
+
self.completed = set(data.get("completed", []))
|
|
68
|
+
self.failed = set(data.get("failed", []))
|
|
69
|
+
self.hashes = dict(data.get("hashes", {}))
|
|
70
|
+
except (json.JSONDecodeError, OSError):
|
|
71
|
+
self.completed = set()
|
|
72
|
+
self.failed = set()
|
|
73
|
+
self.hashes = {}
|
|
74
|
+
|
|
75
|
+
def _save(self):
|
|
76
|
+
data = json.dumps(
|
|
77
|
+
{
|
|
78
|
+
"completed": sorted(self.completed),
|
|
79
|
+
"failed": sorted(self.failed),
|
|
80
|
+
"hashes": self.hashes,
|
|
81
|
+
},
|
|
82
|
+
indent=2,
|
|
83
|
+
)
|
|
84
|
+
atomic_write(self._file, data)
|
|
85
|
+
|
|
86
|
+
def mark_completed(self, task_id: str, task_hash: Optional[str] = None):
|
|
87
|
+
with self._lock:
|
|
88
|
+
self.completed.add(task_id)
|
|
89
|
+
self.failed.discard(task_id)
|
|
90
|
+
if task_hash is not None:
|
|
91
|
+
self.hashes[task_id] = task_hash
|
|
92
|
+
self._save()
|
|
93
|
+
|
|
94
|
+
def needs_rerun(self, task_id: str, current_hash: str) -> bool:
|
|
95
|
+
"""True if the task isn't completed, or its inputs changed since."""
|
|
96
|
+
if task_id not in self.completed:
|
|
97
|
+
return True
|
|
98
|
+
recorded = self.hashes.get(task_id)
|
|
99
|
+
return recorded is None or recorded != current_hash
|
|
100
|
+
|
|
101
|
+
def mark_failed(self, task_id: str):
|
|
102
|
+
with self._lock:
|
|
103
|
+
self.failed.add(task_id)
|
|
104
|
+
self._save()
|
|
105
|
+
|
|
106
|
+
def is_done(self, task_id: str) -> bool:
|
|
107
|
+
return task_id in self.completed
|
|
108
|
+
|
|
109
|
+
def has_previous_run(self) -> bool:
|
|
110
|
+
return bool(self.completed or self.failed)
|
|
111
|
+
|
|
112
|
+
def reset(self):
|
|
113
|
+
self.completed.clear()
|
|
114
|
+
self.failed.clear()
|
|
115
|
+
self.hashes.clear()
|
|
116
|
+
if self._file.exists():
|
|
117
|
+
self._file.unlink()
|
|
118
|
+
|
|
119
|
+
def summary(self) -> str:
|
|
120
|
+
return f"{len(self.completed)} completed, {len(self.failed)} failed"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from misterdev.config import get_setting
|
|
5
|
+
from misterdev.llm.client import BaseLLMClient, create_llm_client
|
|
6
|
+
from misterdev.environments.base_env import BaseEnvironmentManager
|
|
7
|
+
from misterdev.environments.venv_env import VenvEnvironmentManager
|
|
8
|
+
from misterdev.core.task import TaskManager
|
|
9
|
+
from misterdev.core.context.topography import TopographyEngine
|
|
10
|
+
from misterdev.logging_setup import setup_logger
|
|
11
|
+
|
|
12
|
+
logger = setup_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolManager:
|
|
16
|
+
"""Manages initialization and execution of tools."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, tools_config: list):
|
|
19
|
+
self.tools = {}
|
|
20
|
+
# Importing the package registers the built-in tools; the registry also
|
|
21
|
+
# discovers third-party tools from the ``misterdev.tools`` entry-point
|
|
22
|
+
# group, so a plugin adds a tool type with no change here.
|
|
23
|
+
import misterdev.tools # noqa: F401 - registration side effect
|
|
24
|
+
from misterdev.plugins import TOOLS
|
|
25
|
+
|
|
26
|
+
for tc in tools_config:
|
|
27
|
+
tool_type = tc.get("type")
|
|
28
|
+
tool_cls = TOOLS.get(tool_type)
|
|
29
|
+
if tool_cls is None:
|
|
30
|
+
if tool_type:
|
|
31
|
+
logger.warning(
|
|
32
|
+
f"Unknown tool type {tool_type!r}; falling back to command"
|
|
33
|
+
)
|
|
34
|
+
tool_cls = TOOLS.get("command")
|
|
35
|
+
tool = tool_cls(tc)
|
|
36
|
+
self.tools[tool.name] = tool
|
|
37
|
+
|
|
38
|
+
def get_tool(self, name: str):
|
|
39
|
+
return self.tools.get(name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Project:
|
|
43
|
+
"""Represents an active project with all its dependencies initialized."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, path: str | Path, config: dict):
|
|
46
|
+
self.path = Path(path)
|
|
47
|
+
self.config = config
|
|
48
|
+
|
|
49
|
+
self.name = config.get("name", self.path.name)
|
|
50
|
+
self.description = config.get("description", "")
|
|
51
|
+
|
|
52
|
+
self.llm_client = self._init_llm_client()
|
|
53
|
+
self.env_manager = self._init_env_manager()
|
|
54
|
+
self.tool_manager = ToolManager(config.get("tools", []))
|
|
55
|
+
self.task_manager = TaskManager(self)
|
|
56
|
+
# Model ledger/selector are built lazily on first use: they touch the
|
|
57
|
+
# .orchestrator dir and only matter when dynamic_selection is enabled.
|
|
58
|
+
self._model_ledger = None
|
|
59
|
+
self._model_selector = None
|
|
60
|
+
self._llm_cache = None
|
|
61
|
+
self._semantic_ranker = None
|
|
62
|
+
self._ranker_built = False
|
|
63
|
+
self._mcp = None
|
|
64
|
+
self._mcp_built = False
|
|
65
|
+
self._audit_trail = None
|
|
66
|
+
self._governance_policy = None
|
|
67
|
+
self._governance_built = False
|
|
68
|
+
# Topography (symbol graph) is built lazily on first use, not here:
|
|
69
|
+
# every CLI command registers all known projects, and eagerly scanning
|
|
70
|
+
# each one's whole tree just to list/status is wasted work. The executor
|
|
71
|
+
# calls initialize() (idempotent) before it needs the graph.
|
|
72
|
+
self.topography = TopographyEngine(
|
|
73
|
+
self.path,
|
|
74
|
+
self.llm_client,
|
|
75
|
+
golden_paths=get_setting(config, "orchestrator", "golden_paths"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def model_ledger(self):
|
|
80
|
+
"""Persistent per-model performance store (lazy, file-backed)."""
|
|
81
|
+
if self._model_ledger is None:
|
|
82
|
+
from misterdev.core.economics.model_ledger import ModelLedger
|
|
83
|
+
|
|
84
|
+
self._model_ledger = ModelLedger(
|
|
85
|
+
self.path / ".orchestrator" / "model_stats.json"
|
|
86
|
+
)
|
|
87
|
+
return self._model_ledger
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def model_selector(self):
|
|
91
|
+
"""Ledger-driven model selection policy (lazy)."""
|
|
92
|
+
if self._model_selector is None:
|
|
93
|
+
from misterdev.core.economics.model_selector import (
|
|
94
|
+
ModelSelector,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self._model_selector = ModelSelector(
|
|
98
|
+
self.config, self.model_ledger, free_models=self._harvest_free_models()
|
|
99
|
+
)
|
|
100
|
+
return self._model_selector
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def semantic_ranker(self):
|
|
104
|
+
"""Embedding-based context ranker, or None when unavailable/disabled.
|
|
105
|
+
|
|
106
|
+
Built at most once (discovery hits the network); a None result is
|
|
107
|
+
remembered so topography falls back to arbitrary order without retrying.
|
|
108
|
+
"""
|
|
109
|
+
if not self._ranker_built:
|
|
110
|
+
self._ranker_built = True
|
|
111
|
+
if get_setting(self.config, "llm", "semantic_retrieval"):
|
|
112
|
+
from misterdev.core.economics.embeddings import (
|
|
113
|
+
EmbeddingCache,
|
|
114
|
+
SemanticRanker,
|
|
115
|
+
)
|
|
116
|
+
from misterdev.llm.client import create_embedding_client
|
|
117
|
+
|
|
118
|
+
weight = get_setting(self.config, "llm", "lexical_weight")
|
|
119
|
+
embedder = create_embedding_client(self.config)
|
|
120
|
+
cache = (
|
|
121
|
+
EmbeddingCache(
|
|
122
|
+
self.path / ".orchestrator" / "embeddings.json", embedder.model
|
|
123
|
+
)
|
|
124
|
+
if embedder is not None
|
|
125
|
+
else None
|
|
126
|
+
)
|
|
127
|
+
# Always build a ranker: with no embedder it ranks lexically,
|
|
128
|
+
# which still beats the arbitrary-order slice.
|
|
129
|
+
self._semantic_ranker = SemanticRanker(embedder, cache, weight)
|
|
130
|
+
return self._semantic_ranker
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def mcp(self):
|
|
134
|
+
"""MCP tool-host manager, or None when no servers are configured.
|
|
135
|
+
|
|
136
|
+
Built at most once (the manager itself is cheap; discovery is deferred
|
|
137
|
+
to first access of ``.tools`` and is timeout-bounded). A None result is
|
|
138
|
+
remembered so callers can skip MCP entirely without retrying.
|
|
139
|
+
"""
|
|
140
|
+
if not self._mcp_built:
|
|
141
|
+
self._mcp_built = True
|
|
142
|
+
mcp_cfg = self.config.get("mcp") or {}
|
|
143
|
+
servers = mcp_cfg.get("servers") or []
|
|
144
|
+
if servers:
|
|
145
|
+
from misterdev.core.integration.mcp import MCPManager
|
|
146
|
+
|
|
147
|
+
manager = MCPManager(servers, allow_tools=mcp_cfg.get("allow_tools"))
|
|
148
|
+
self._mcp = manager if manager.enabled else None
|
|
149
|
+
return self._mcp
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def llm_cache(self):
|
|
153
|
+
"""Response memoization store, or None when caching is disabled."""
|
|
154
|
+
if self._llm_cache is None and get_setting(self.config, "llm", "cache"):
|
|
155
|
+
from misterdev.core.economics.llm_cache import LLMCache
|
|
156
|
+
|
|
157
|
+
self._llm_cache = LLMCache(self.path / ".orchestrator" / "llm_cache")
|
|
158
|
+
return self._llm_cache
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def audit_trail(self):
|
|
162
|
+
"""Append-only JSONL audit trail (lazy, file-backed under .orchestrator).
|
|
163
|
+
|
|
164
|
+
Defaults ON: it only appends observability records to a gitignored file
|
|
165
|
+
and degrades to a no-op if the path is unwritable, so it cannot regress a
|
|
166
|
+
build. A run that wants it silent leaves the file unread (no behavioral
|
|
167
|
+
effect either way)."""
|
|
168
|
+
if self._audit_trail is None:
|
|
169
|
+
from misterdev.core.audit import AuditTrail
|
|
170
|
+
|
|
171
|
+
self._audit_trail = AuditTrail(self.path, enabled=True)
|
|
172
|
+
return self._audit_trail
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def governance_policy(self):
|
|
176
|
+
"""Risk-classified approval policy, or None when governance is off.
|
|
177
|
+
|
|
178
|
+
Built from config in AUTONOMOUS mode (interactive prompting is a deferred
|
|
179
|
+
seam: threading stdin into the wave loop risks a hang, so unattended runs
|
|
180
|
+
BLOCK a risky command and record an escalation unless governance.
|
|
181
|
+
auto_approve is set). None when orchestrator.governance is false, so the
|
|
182
|
+
command seam stays byte-identical to today."""
|
|
183
|
+
if not self._governance_built:
|
|
184
|
+
self._governance_built = True
|
|
185
|
+
from misterdev.core.execution.governance import (
|
|
186
|
+
policy_from_config,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
policy = policy_from_config(
|
|
190
|
+
self.config, interactive=False, audit=self.audit_trail
|
|
191
|
+
)
|
|
192
|
+
self._governance_policy = policy if policy.enabled else None
|
|
193
|
+
return self._governance_policy
|
|
194
|
+
|
|
195
|
+
def _harvest_free_models(self) -> list:
|
|
196
|
+
"""Current free OpenRouter models when use_free_models is enabled."""
|
|
197
|
+
if not get_setting(self.config, "llm", "use_free_models"):
|
|
198
|
+
return []
|
|
199
|
+
import time
|
|
200
|
+
|
|
201
|
+
from misterdev.core.economics.free_models import FreeModelCache
|
|
202
|
+
|
|
203
|
+
cache = FreeModelCache(self.path / ".orchestrator" / "free_models.json")
|
|
204
|
+
try:
|
|
205
|
+
return cache.get(time.time())
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning(f"Free-model harvest skipped: {e}")
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def _init_llm_client(self) -> BaseLLMClient:
|
|
211
|
+
return create_llm_client(self.config)
|
|
212
|
+
|
|
213
|
+
def _init_env_manager(self) -> Optional[BaseEnvironmentManager]:
|
|
214
|
+
env_config = self.config.get("environment", {})
|
|
215
|
+
env_type = env_config.get("type")
|
|
216
|
+
if env_type == "venv":
|
|
217
|
+
return VenvEnvironmentManager(env_config, self.path)
|
|
218
|
+
if env_type in ("docker", "container"):
|
|
219
|
+
from misterdev.environments.container_env import (
|
|
220
|
+
ContainerEnvironmentManager,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
gov = self.config.get("governance") or {}
|
|
224
|
+
network = gov.get("network") if gov.get("network") == "none" else None
|
|
225
|
+
return ContainerEnvironmentManager(
|
|
226
|
+
env_config,
|
|
227
|
+
self.path,
|
|
228
|
+
language=self.config.get("language", ""),
|
|
229
|
+
network=network,
|
|
230
|
+
)
|
|
231
|
+
return None
|