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,495 @@
1
+ import ast
2
+ import re
3
+ import subprocess
4
+ from typing import Callable, Dict, List, Optional, Tuple
5
+ from pathlib import Path
6
+
7
+ from misterdev.core.planning.assessment import HealthCheck
8
+ from misterdev.core.gitcmd import run_git
9
+ from misterdev.logging_setup import setup_logger
10
+ from misterdev.utils.process import kill_process_group
11
+
12
+ logger = setup_logger(__name__)
13
+
14
+
15
+ # ----------------------------------------------------------------
16
+ # Build/Test/Lint Validation (Phase 5 from /build skill)
17
+ # ----------------------------------------------------------------
18
+
19
+
20
+ class ValidationResult:
21
+ def __init__(self):
22
+ self.build_ok: bool = False
23
+ self.build_output: str = ""
24
+ self.tests_ok: bool = False
25
+ self.test_output: str = ""
26
+ self.lint_ok: bool = False
27
+ self.lint_output: str = ""
28
+ self.diff_stats: str = ""
29
+ self.issues: list[str] = []
30
+ # Whether each gate actually executed a command. A gate that did not
31
+ # run must not be reported as "OK" (that hides untested code).
32
+ self.build_ran: bool = True
33
+ self.tests_ran: bool = True
34
+ self.lint_ran: bool = True
35
+
36
+ @property
37
+ def passed(self) -> bool:
38
+ # A gate that was absent is non-blocking (build-only / test-only projects
39
+ # are valid), but if NOTHING ran at all then nothing was actually
40
+ # verified — don't report that as passed (mirrors why summary() shows
41
+ # SKIP instead of OK).
42
+ ran = self.build_ran or self.tests_ran or self.lint_ran
43
+ return ran and self.build_ok and self.tests_ok and not self.issues
44
+
45
+ def _status(self, ran: bool, ok: bool, warn: bool = False) -> str:
46
+ if not ran:
47
+ return "SKIP"
48
+ if ok:
49
+ return "OK"
50
+ return "WARN" if warn else "FAIL"
51
+
52
+ def summary(self) -> str:
53
+ parts = [
54
+ f"build={self._status(self.build_ran, self.build_ok)}",
55
+ f"tests={self._status(self.tests_ran, self.tests_ok)}",
56
+ f"lint={self._status(self.lint_ran, self.lint_ok, warn=True)}",
57
+ ]
58
+ if self.issues:
59
+ parts.append(f"issues={len(self.issues)}")
60
+ return " | ".join(parts)
61
+
62
+
63
+ def _run_cmd(
64
+ cmd: str,
65
+ cwd: Path,
66
+ env_activate: Optional[str] = None,
67
+ timeout: int = 180,
68
+ runner: Optional[Callable[[str, int], Tuple[bool, str]]] = None,
69
+ policy: Optional[object] = None,
70
+ audit: Optional[object] = None,
71
+ ) -> Tuple[bool, str]:
72
+ # Governance gate (opt-in): when a policy is supplied AND it refuses the
73
+ # command, return a refusal without executing. Both args default to None, so
74
+ # with no policy/audit this is byte-identical to the prior behavior. The
75
+ # classifier sees the raw command, before the host activation prefix.
76
+ if policy is not None:
77
+ try:
78
+ decision = policy.authorize(cmd)
79
+ except Exception: # a policy bug must never break the command seam
80
+ decision = None
81
+ if decision is not None and not decision.allowed:
82
+ msg = f"Command refused by governance: {decision.reason}"
83
+ logger.warning(f"{msg}: {cmd}")
84
+ return False, msg
85
+ # When a runner is supplied (e.g. a container engine), the command executes
86
+ # through it instead of the local subprocess. Activation prefixes are
87
+ # host-venv concepts, so they are only applied to local execution.
88
+ if runner is not None:
89
+ ok, output = runner(cmd, timeout)
90
+ _audit_command(audit, cmd, ok, cwd)
91
+ return ok, output
92
+ if env_activate:
93
+ full_cmd = f"{env_activate} && {cmd}"
94
+ else:
95
+ full_cmd = cmd
96
+ try:
97
+ # start_new_session puts the command in its own process group so a
98
+ # timeout can SIGKILL the WHOLE tree (build/test grandchildren like rustc
99
+ # or pytest workers), not just the shell — otherwise they outlive the
100
+ # gate and hold the target/ lock. errors="replace" keeps a stray non-UTF8
101
+ # byte in tool output from raising and being misreported as a failure.
102
+ proc = subprocess.Popen(
103
+ full_cmd,
104
+ shell=True,
105
+ cwd=cwd,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.PIPE,
108
+ text=True,
109
+ errors="replace",
110
+ start_new_session=True,
111
+ )
112
+ try:
113
+ out, err = proc.communicate(timeout=timeout)
114
+ except subprocess.TimeoutExpired:
115
+ kill_process_group(proc)
116
+ # Bound the reap: a grandchild that escaped the process group (a
117
+ # daemonizing server) holds the inherited pipe, so an unbounded
118
+ # communicate() would hang the gate after the timeout already fired.
119
+ try:
120
+ proc.communicate(timeout=5)
121
+ except subprocess.TimeoutExpired:
122
+ pass
123
+ _audit_command(audit, cmd, False, cwd)
124
+ return False, f"Command timed out after {timeout}s: {full_cmd}"
125
+ output = out
126
+ if err:
127
+ output += "\n" + err
128
+ ok = proc.returncode == 0
129
+ _audit_command(audit, cmd, ok, cwd)
130
+ return ok, output
131
+ except Exception as e:
132
+ _audit_command(audit, cmd, False, cwd)
133
+ return False, f"Command failed: {e}"
134
+
135
+
136
+ def _audit_command(audit: Optional[object], cmd: str, ok: bool, cwd: Path) -> None:
137
+ """Record a command event if an audit trail is wired. Never raises."""
138
+ if audit is None:
139
+ return
140
+ try:
141
+ audit.record_command(cmd, ok=ok, cwd=str(cwd))
142
+ except Exception as e: # audit is observability; it must never break execution
143
+ # Swallow so a broken audit sink can't fail a command, but leave a trace
144
+ # so silently-vanishing audit records are diagnosable.
145
+ logger.debug(f"Audit record_command failed (ignored): {e}")
146
+
147
+
148
+ def run_validation(
149
+ project_path: Path,
150
+ build_command: Optional[str],
151
+ test_command: Optional[str],
152
+ lint_command: Optional[str],
153
+ env_activate: Optional[str] = None,
154
+ timeout: int = 180,
155
+ ) -> ValidationResult:
156
+ """Run full validation suite (Phase 5a-5d from /build)."""
157
+ result = ValidationResult()
158
+
159
+ if build_command:
160
+ logger.info(f"Validation: running build command: {build_command}")
161
+ result.build_ok, result.build_output = _run_cmd(
162
+ build_command,
163
+ project_path,
164
+ env_activate,
165
+ timeout,
166
+ )
167
+ if not result.build_ok:
168
+ result.issues.append("Build failed during validation")
169
+ else:
170
+ result.build_ok = True
171
+ result.build_ran = False
172
+
173
+ if test_command:
174
+ logger.info(f"Validation: running test command: {test_command}")
175
+ result.tests_ok, result.test_output = _run_cmd(
176
+ test_command,
177
+ project_path,
178
+ env_activate,
179
+ timeout,
180
+ )
181
+ if not result.tests_ok:
182
+ result.issues.append("Tests failed during validation")
183
+ else:
184
+ result.tests_ok = True
185
+ result.tests_ran = False
186
+
187
+ if lint_command:
188
+ logger.info(f"Validation: running lint command: {lint_command}")
189
+ result.lint_ok, result.lint_output = _run_cmd(
190
+ lint_command,
191
+ project_path,
192
+ env_activate,
193
+ timeout,
194
+ )
195
+ if not result.lint_ok:
196
+ result.issues.append("Lint warnings found during validation")
197
+ else:
198
+ result.lint_ok = True
199
+ result.lint_ran = False
200
+
201
+ proc = run_git("git diff --stat", project_path)
202
+ result.diff_stats = proc.stdout.strip() if proc else "(unable to get diff stats)"
203
+
204
+ return result
205
+
206
+
207
+ def run_health_check(
208
+ project_path: Path,
209
+ build_command: Optional[str],
210
+ test_command: Optional[str],
211
+ lint_command: Optional[str],
212
+ env_activate: Optional[str] = None,
213
+ timeout: int = 120,
214
+ build_timeout: Optional[int] = None,
215
+ test_timeout: Optional[int] = None,
216
+ lint_timeout: Optional[int] = None,
217
+ ) -> HealthCheck:
218
+ """Run health check and return structured result for assessment.
219
+
220
+ Per-command timeouts override the shared ``timeout`` so a slow compiler or
221
+ linter (e.g. cargo with ort/tokenizers, clippy over a workspace) isn't
222
+ falsely reported as failing when it merely exceeds the default.
223
+ """
224
+ health = HealthCheck()
225
+ bt = build_timeout if build_timeout is not None else timeout
226
+ tt = test_timeout if test_timeout is not None else timeout
227
+ lt = lint_timeout if lint_timeout is not None else tt
228
+
229
+ # A command that is absent is "not applicable", not "failing" — leaving the
230
+ # field at its False default would make a no-build-step (or no-lint) project
231
+ # read as build=FAIL and mislead the analyzers. Mirror run_validation, which
232
+ # treats an absent command as a pass.
233
+ if build_command:
234
+ health.builds, health.build_output = _run_cmd(
235
+ build_command,
236
+ project_path,
237
+ env_activate,
238
+ bt,
239
+ )
240
+ else:
241
+ health.builds = True
242
+
243
+ if test_command:
244
+ health.tests_pass, health.test_output = _run_cmd(
245
+ test_command,
246
+ project_path,
247
+ env_activate,
248
+ tt,
249
+ )
250
+ health.test_count, health.test_failures = _parse_test_counts(health.test_output)
251
+ else:
252
+ health.tests_pass = True
253
+
254
+ if lint_command:
255
+ health.lint_clean, health.lint_output = _run_cmd(
256
+ lint_command,
257
+ project_path,
258
+ env_activate,
259
+ lt,
260
+ )
261
+ else:
262
+ health.lint_clean = True
263
+
264
+ return health
265
+
266
+
267
+ def _parse_test_counts(output: str) -> Tuple[int, int]:
268
+ """Extract (total, failures) from common test-runner output.
269
+
270
+ Without this, test_count stays 0 and assessment.summary() renders
271
+ 'tests=none' even on a fully-passing suite, which misleads the analyzers
272
+ and recommender into believing no tests exist.
273
+ """
274
+ passed = failed = 0
275
+ # pytest: "N passed", "N failed", "N error(s)", "N skipped". Sum EVERY match
276
+ # so a multi-package run (more than one summary line) isn't undercounted to
277
+ # just the first block.
278
+ for kind, target in (
279
+ ("passed", "p"),
280
+ ("failed", "f"),
281
+ ("error", "f"),
282
+ ("errors", "f"),
283
+ ):
284
+ for n in re.findall(rf"(\d+) {kind}\b", output):
285
+ if target == "p":
286
+ passed += int(n)
287
+ else:
288
+ failed += int(n)
289
+ if passed or failed:
290
+ return passed + failed, failed
291
+ # cargo: "test result: ok. N passed; M failed" — one line per crate in a
292
+ # workspace run, so sum them all rather than counting only the first crate.
293
+ cargo = re.findall(r"test result:.*?(\d+) passed; (\d+) failed", output)
294
+ if cargo:
295
+ p = sum(int(a) for a, _ in cargo)
296
+ f = sum(int(b) for _, b in cargo)
297
+ return p + f, f
298
+ # swift test / XCTest: "Executed N tests, with M failures"
299
+ m = re.search(r"Executed (\d+) tests?, with (\d+) failure", output)
300
+ if m:
301
+ total, f = int(m.group(1)), int(m.group(2))
302
+ return total, f
303
+ # ctest: "X% tests passed, M tests failed out of N"
304
+ m = re.search(r"tests passed,\s*(\d+) tests? failed out of (\d+)", output)
305
+ if m:
306
+ f, total = int(m.group(1)), int(m.group(2))
307
+ return total, f
308
+ # dotnet test (VSTest): "Failed: N, Passed: M, Skipped: K, Total: T"
309
+ fm = re.search(r"Failed:\s*(\d+)", output)
310
+ tm = re.search(r"Total:\s*(\d+)", output)
311
+ if fm and tm:
312
+ return int(tm.group(1)), int(fm.group(1))
313
+ # dotnet test (alt): "Total tests: T. Passed: M. Failed: N."
314
+ m = re.search(r"Total tests:\s*(\d+)\..*?Failed:\s*(\d+)", output, re.DOTALL)
315
+ if m:
316
+ return int(m.group(1)), int(m.group(2))
317
+ # node --test: "ℹ tests N" / "ℹ fail K" (default reporter) or the TAP
318
+ # equivalent "# tests N" / "# fail K".
319
+ tm = re.search(r"(?:ℹ|#)\s*tests\s+(\d+)", output)
320
+ fm = re.search(r"(?:ℹ|#)\s*fail\s+(\d+)", output)
321
+ if tm and fm:
322
+ return int(tm.group(1)), int(fm.group(1))
323
+ return 0, 0
324
+
325
+
326
+ # ----------------------------------------------------------------
327
+ # Code Quality Validators
328
+ # ----------------------------------------------------------------
329
+
330
+
331
+ class CodeValidator:
332
+ """Validates code syntax using AST parsing and structural checks."""
333
+
334
+ @staticmethod
335
+ def validate_code(
336
+ content: str, language: str = "python"
337
+ ) -> Tuple[bool, Optional[str]]:
338
+ lang = language.lower()
339
+ if lang in ["python", "py"]:
340
+ try:
341
+ ast.parse(content)
342
+ return True, None
343
+ except SyntaxError as e:
344
+ error_msg = f"Syntax error at line {e.lineno}, col {e.offset}: {e.msg}"
345
+ return False, error_msg
346
+ # Shell syntax (command substitution $(), arithmetic (()), here-docs)
347
+ # uses parentheses in ways the delimiter matcher misreports. Skip the
348
+ # bracket check for shell rather than emit false "unclosed delimiter".
349
+ if lang in ["shell", "sh", "bash", "zsh"]:
350
+ return True, None
351
+ # Real parse-based check for languages with a trustworthy grammar. It
352
+ # understands strings/comments (so braces in a literal don't false-trip)
353
+ # and catches actual syntax errors that brace-balancing misses.
354
+ try:
355
+ from misterdev.core.context.topography import check_syntax
356
+
357
+ result = check_syntax(content, lang)
358
+ except ImportError:
359
+ result = None
360
+ if result is not None:
361
+ return result
362
+ return CodeValidator._basic_integrity_check(content)
363
+
364
+ @staticmethod
365
+ def _basic_integrity_check(content: str) -> Tuple[bool, Optional[str]]:
366
+ delimiters = {"(": ")", "[": "]", "{": "}"}
367
+ stack = []
368
+ for i, char in enumerate(content):
369
+ if char in delimiters:
370
+ stack.append((char, i))
371
+ elif char in delimiters.values():
372
+ if not stack:
373
+ return False, f"Unmatched closing delimiter '{char}' at index {i}"
374
+ top, _ = stack.pop()
375
+ if delimiters[top] != char:
376
+ return False, f"Mismatched delimiter '{char}' at index {i}"
377
+ if stack:
378
+ top, idx = stack[0]
379
+ return False, f"Unclosed delimiter '{top}' at index {idx}"
380
+ return True, None
381
+
382
+
383
+ class CertaintyScorer:
384
+ """Heuristic-based LLM certainty analyzer."""
385
+
386
+ HEDGES = [
387
+ "maybe",
388
+ "perhaps",
389
+ "might",
390
+ "could be",
391
+ "not sure",
392
+ "i think",
393
+ "possibly",
394
+ "unclear",
395
+ "hard to say",
396
+ "it depends",
397
+ "arguably",
398
+ "i'm not certain",
399
+ "one possibility",
400
+ ]
401
+
402
+ ASSERTIONS = [
403
+ "verified",
404
+ "confirmed",
405
+ "optimal",
406
+ "correct",
407
+ "proven",
408
+ "tests pass",
409
+ "all tests",
410
+ "successfully",
411
+ "no issues",
412
+ "the solution is",
413
+ "this fixes",
414
+ "this resolves",
415
+ "implemented",
416
+ "works",
417
+ "complete",
418
+ "done",
419
+ ]
420
+
421
+ @staticmethod
422
+ def compute_score(content: str) -> float:
423
+ lower_content = content.lower()
424
+ score = 0.5
425
+ hedge_count = sum(
426
+ 1 for hedge in CertaintyScorer.HEDGES if hedge in lower_content
427
+ )
428
+ score -= min(hedge_count * 0.1, 0.4)
429
+ assertion_count = sum(
430
+ 1 for a in CertaintyScorer.ASSERTIONS if a in lower_content
431
+ )
432
+ score += min(assertion_count * 0.1, 0.4)
433
+ if "```" in content:
434
+ score += 0.1
435
+ if len(content.split()) < 20:
436
+ score -= 0.1
437
+ return max(0.0, min(1.0, score))
438
+
439
+
440
+ class StallDetector:
441
+ """Detects oscillation or lack of progress in task execution history.
442
+
443
+ Uses token-based Jaccard Similarity to detect 'semantic' stalling where
444
+ edits are nearly identical but not byte-perfect matches.
445
+ """
446
+
447
+ def __init__(self, history_limit: int = 5, similarity_threshold: float = 0.95):
448
+ self.history_limit = history_limit
449
+ self.similarity_threshold = similarity_threshold
450
+ self._history: List[str] = []
451
+
452
+ def push_edit(self, edits: Dict[str, str]) -> float:
453
+ """Records an edit and returns a stall risk score [0.0 - 1.0]."""
454
+ sorted_keys = sorted(edits.keys())
455
+ current_content = " ".join(f"{k} {edits[k]}" for k in sorted_keys)
456
+ current_tokens = _tokenize(current_content)
457
+
458
+ if not current_tokens:
459
+ return 0.0
460
+
461
+ max_similarity = 0.0
462
+ for prev_content in self._history:
463
+ prev_tokens = _tokenize(prev_content)
464
+ intersection = len(current_tokens & prev_tokens)
465
+ union = len(current_tokens | prev_tokens)
466
+ similarity = intersection / union if union > 0 else 0.0
467
+ max_similarity = max(max_similarity, similarity)
468
+
469
+ self._history.append(current_content)
470
+ if len(self._history) > self.history_limit:
471
+ self._history.pop(0)
472
+
473
+ if max_similarity > self.similarity_threshold:
474
+ return 0.8 + (
475
+ 0.2
476
+ * (max_similarity - self.similarity_threshold)
477
+ / (1.0 - self.similarity_threshold)
478
+ )
479
+
480
+ return max_similarity * 0.5
481
+
482
+
483
+ def _tokenize(text: str) -> set:
484
+ """Extract word tokens from text without regex."""
485
+ tokens = set()
486
+ current = []
487
+ for char in text.lower():
488
+ if char.isalnum() or char == "_":
489
+ current.append(char)
490
+ elif current:
491
+ tokens.add("".join(current))
492
+ current = []
493
+ if current:
494
+ tokens.add("".join(current))
495
+ return tokens
@@ -0,0 +1,185 @@
1
+ """Optional vision verification gate.
2
+
3
+ Some acceptance criteria are visual ("the dashboard shows a chart", "the layout
4
+ is not broken") and cannot be asserted by a DOM/text check. This gate takes a
5
+ screenshot path and asks a vision-language model a yes/no question — "does this
6
+ image satisfy: <assert>?" — capturing the model's verdict and reason as
7
+ evidence.
8
+
9
+ It mirrors :mod:`misterdev.core.execution.runtime`: strictly opt-in (off
10
+ unless ``runtime.vision`` is configured), best-effort, and run in a daemon worker
11
+ thread with a hard timeout so a slow or unreachable model can NEVER block the
12
+ build. Absent config, no model/network, or a timeout is a SKIP (no opinion), not
13
+ a failure; only a model that affirmatively denies the assertion is a RED, and
14
+ only an affirmation is a GREEN.
15
+
16
+ Unlike the web gate (which captures objective browser evidence), this gate's
17
+ signal IS a model judgment — so it asserts a concrete image (real pixels the
18
+ build produced) rather than letting the model self-report on code it wrote.
19
+ """
20
+
21
+ import base64
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Callable, Optional
25
+
26
+ from misterdev.core.execution.bounded import run_bounded
27
+ from misterdev.core.execution.outcomes import (
28
+ GREEN,
29
+ RED,
30
+ SKIP,
31
+ GateOutcome,
32
+ )
33
+ from misterdev.logging_setup import setup_logger
34
+
35
+ logger = setup_logger(__name__)
36
+
37
+ # Outcome constants. SKIP means "no opinion" (no config, no model/network, or
38
+ # timeout) and must never be treated as a pass/fail signal by callers.
39
+ _PROMPT = (
40
+ "You are a strict visual acceptance checker. Look at the attached screenshot "
41
+ "and answer ONLY whether it satisfies this requirement:\n\n"
42
+ "{assertion}\n\n"
43
+ "Reply with 'YES' or 'NO' on the first line, then a one-sentence reason."
44
+ )
45
+
46
+ # A vision call takes the prompt text and the base64-encoded PNG and returns the
47
+ # model's text verdict. Injected in tests; defaulted to the project client path.
48
+ VlmCall = Callable[[str, str], str]
49
+
50
+
51
+ class VisionResult(GateOutcome):
52
+ """Outcome of a vision check. ``status`` is SKIP/GREEN/RED; ``verdict`` is
53
+ the raw model text (evidence); ``reason`` explains a SKIP/RED."""
54
+
55
+ def __init__(self, status: str, verdict: str = "", reason: str = ""):
56
+ super().__init__(status, reason)
57
+ self.verdict = verdict
58
+
59
+ def __repr__(self) -> str:
60
+ return f"VisionResult(status={self.status!r}, reason={self.reason!r})"
61
+
62
+
63
+ def run_vision_gate(
64
+ project_root: Path,
65
+ vision_config: Optional[dict],
66
+ vlm_call: Optional[VlmCall] = None,
67
+ llm_client=None,
68
+ ) -> VisionResult:
69
+ """Run the vision gate described by ``vision_config``.
70
+
71
+ ``vision_config`` keys:
72
+ - ``capture`` (required): path to the screenshot to evaluate, relative to
73
+ ``project_root`` or absolute.
74
+ - ``assert`` (required): the requirement the image must satisfy.
75
+ - ``model`` (optional): vision model id; forwarded to the client.
76
+ - ``timeout`` (optional, default 60): hard ceiling for the whole run.
77
+
78
+ The model call is performed by ``vlm_call`` when supplied (the test seam);
79
+ otherwise a call is built from ``llm_client`` if one is provided. With
80
+ neither a callable nor a client, the gate SKIPs (no model/network). SKIP also
81
+ on absent config, a missing capture file, or the hard timeout (never blocks).
82
+ """
83
+ if not vision_config or not vision_config.get("capture"):
84
+ return VisionResult(SKIP, reason="no runtime.vision config")
85
+ assertion = vision_config.get("assert")
86
+ if not assertion:
87
+ return VisionResult(SKIP, reason="no assert in runtime.vision config")
88
+
89
+ capture = Path(vision_config["capture"])
90
+ if not capture.is_absolute():
91
+ capture = project_root / capture
92
+ if not capture.is_file():
93
+ return VisionResult(SKIP, reason=f"capture file not found: {capture}")
94
+
95
+ model = vision_config.get("model")
96
+ timeout = float(vision_config.get("timeout", 60))
97
+
98
+ call = vlm_call or _default_vlm_call(llm_client, model)
99
+ if call is None:
100
+ return VisionResult(SKIP, reason="no vision model available")
101
+
102
+ def _work() -> VisionResult:
103
+ try:
104
+ return _verify(capture, assertion, call)
105
+ except Exception as e: # any model/IO failure is non-fatal -> skip
106
+ logger.debug(f"Vision verify gate unavailable: {e}")
107
+ return VisionResult(SKIP, reason=f"error: {e}")
108
+
109
+ return run_bounded(
110
+ _work, timeout, VisionResult(SKIP, reason="timed out"), "Vision verify gate"
111
+ )
112
+
113
+
114
+ def _verify(capture: Path, assertion: str, call: VlmCall) -> VisionResult:
115
+ """Encode the screenshot, ask the model, parse the YES/NO verdict."""
116
+ image_b64 = base64.b64encode(capture.read_bytes()).decode("ascii")
117
+ prompt = _PROMPT.format(assertion=assertion)
118
+ verdict = call(prompt, image_b64) or ""
119
+ decision = _parse_verdict(verdict)
120
+ if decision is True:
121
+ return VisionResult(GREEN, verdict=verdict)
122
+ if decision is False:
123
+ return VisionResult(RED, verdict=verdict, reason=verdict.strip())
124
+ # Model gave no parseable yes/no -> no opinion, not a failure.
125
+ return VisionResult(SKIP, verdict=verdict, reason="unparseable verdict")
126
+
127
+
128
+ def _parse_verdict(text: str) -> Optional[bool]:
129
+ """True for an affirmation, False for a denial, None when unparseable.
130
+
131
+ Matches a leading YES/NO token (the prompt asks for it on the first line) to
132
+ avoid being fooled by the word appearing inside the reason sentence.
133
+ """
134
+ if not text:
135
+ return None
136
+ head = text.strip().splitlines()[0] if text.strip() else ""
137
+ if re.match(r"^\s*(yes|true|pass|affirm)\b", head, re.IGNORECASE):
138
+ return True
139
+ if re.match(r"^\s*(no|false|fail|deny)\b", head, re.IGNORECASE):
140
+ return False
141
+ return None
142
+
143
+
144
+ def _default_vlm_call(llm_client, model: Optional[str]) -> Optional[VlmCall]:
145
+ """Build a vision call from the project's LLM client, or None if unusable.
146
+
147
+ Sends a multimodal message (text + a base64 PNG image) and returns the
148
+ model's text. Prefers the client's first-class ``chat_multimodal`` method;
149
+ falls back to driving the raw OpenAI-compatible ``.client`` SDK directly for
150
+ clients that predate it. Kept tolerant of client shape so an absent/limited
151
+ client degrades to SKIP rather than raising. No network is touched until the
152
+ returned callable is actually invoked inside the worker thread.
153
+ """
154
+ if llm_client is None:
155
+ return None
156
+
157
+ def _call(prompt: str, image_b64: str) -> str:
158
+ multimodal = getattr(llm_client, "chat_multimodal", None)
159
+ if callable(multimodal):
160
+ return multimodal(prompt, image_b64, model)
161
+ # Fallback: drive the raw OpenAI-compatible SDK client directly.
162
+ # with_model is a context manager (not a client factory), so we must not
163
+ # rebind through it; the explicit ``model=`` below selects the vision
164
+ # model.
165
+ raw = getattr(llm_client, "client", None)
166
+ if raw is None or not hasattr(raw, "chat"):
167
+ raise RuntimeError("client does not expose a multimodal chat endpoint")
168
+ resp = raw.chat.completions.create(
169
+ model=model or getattr(llm_client, "model", None),
170
+ messages=[
171
+ {
172
+ "role": "user",
173
+ "content": [
174
+ {"type": "text", "text": prompt},
175
+ {
176
+ "type": "image_url",
177
+ "image_url": {"url": f"data:image/png;base64,{image_b64}"},
178
+ },
179
+ ],
180
+ }
181
+ ],
182
+ )
183
+ return resp.choices[0].message.content or ""
184
+
185
+ return _call