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,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