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,727 @@
1
+ """The Try-Test-Fix execution loop (``execute``)."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from misterdev.core.economics.context_budget import ContextBudget
6
+ from misterdev.core.models import Task, ExecutionResult
7
+ from misterdev.core.execution.project import Project
8
+ from misterdev.core.verification.validator import (
9
+ CodeValidator,
10
+ CertaintyScorer,
11
+ )
12
+ from misterdev.core.execution.error_resolver import ErrorResolver
13
+ from misterdev.core.execution.error_classifier import (
14
+ format_classified_error,
15
+ )
16
+ from misterdev.llm.client import CACHE_BREAKPOINT
17
+ from misterdev.llm.prompt_manager import PromptManager
18
+ from misterdev.config import get_setting
19
+
20
+ from .helpers import (
21
+ logger,
22
+ _is_golden_path,
23
+ _detect_language,
24
+ EDIT_FORMAT_INSTRUCTIONS,
25
+ FULL_FILE_FALLBACK_INSTRUCTIONS,
26
+ )
27
+
28
+
29
+ class ExecuteMixin:
30
+ def execute(
31
+ self, task: Task, project: Project, use_git_branch: bool = True, _depth: int = 0
32
+ ) -> ExecutionResult:
33
+ logger.info(f"Starting execution of task: {task.id}")
34
+ project.task_manager.update_task_status(task.id, "in_progress")
35
+
36
+ prompt_manager = PromptManager(project.config)
37
+ processor_config = self._get_processor_config(project)
38
+
39
+ # Read max_task_attempts from orchestrator config, falling back to processor config, then hardcoded default
40
+ default_attempts = get_setting(
41
+ project.config, "orchestrator", "max_task_attempts"
42
+ )
43
+ max_retries = processor_config.get("max_retries_per_task", default_attempts)
44
+
45
+ # Read context budget tokens from orchestrator config or task processor_data
46
+ context_budget_tokens = task.processor_data.get(
47
+ "context_budget_tokens",
48
+ get_setting(project.config, "orchestrator", "context_budget_tokens"),
49
+ )
50
+
51
+ # Minimum LLM certainty required to accept a task as completed when no
52
+ # test gate ran. Deterministic: compared against the heuristic certainty
53
+ # score only, never another LLM call.
54
+ certainty_threshold = get_setting(
55
+ project.config, "orchestrator", "certainty_threshold"
56
+ )
57
+
58
+ # Per-task acceptance gate. Cheap deterministic command path is on by
59
+ # default; the LLM-judge fallback is off by default so the default path
60
+ # adds zero extra LLM calls. When no command is found and the judge is
61
+ # off, acceptance is a no-op (behaviour identical to before this gate).
62
+ verify_acceptance = get_setting(
63
+ project.config, "orchestrator", "verify_acceptance"
64
+ )
65
+ llm_acceptance_judge = get_setting(
66
+ project.config, "orchestrator", "llm_acceptance_judge"
67
+ )
68
+
69
+ files_key = processor_config.get("target_files_key", "files_to_modify")
70
+ test_cmd_key = processor_config.get("test_command_key", "test_command")
71
+ typecheck_cmd_key = processor_config.get(
72
+ "typecheck_command_key", "typecheck_command"
73
+ )
74
+
75
+ target_files = task.processor_data.get(files_key, [])
76
+ if isinstance(target_files, str):
77
+ target_files = [target_files]
78
+ target_files = list(
79
+ set(target_files + task.files_to_modify + task.files_to_create)
80
+ )
81
+
82
+ # Golden suite: never task the model with these files and never read
83
+ # them into its context. Combined with the edit-time rejection in
84
+ # _validate_edit_paths, the model cannot see or alter them.
85
+ golden_paths = get_setting(project.config, "orchestrator", "golden_paths")
86
+ target_files = [f for f in target_files if not _is_golden_path(f, golden_paths)]
87
+ context_files = [
88
+ f for f in task.context_files if not _is_golden_path(f, golden_paths)
89
+ ]
90
+
91
+ # Multi-target routing: pick the sub-project that owns this task's files
92
+ # and gate with ITS build/test/typecheck commands. No targets / no match
93
+ # -> top-level commands, i.e. the single-target path is unchanged.
94
+ from misterdev.core.planning.targets import (
95
+ select_target,
96
+ target_commands,
97
+ )
98
+
99
+ routed_target = select_target(project.config.get("targets") or [], target_files)
100
+ target_cmds = target_commands(routed_target, project.config)
101
+ # Routed gates run in the TARGET's directory (so `npm run typecheck`
102
+ # resolves under clients/web, not the repo root). None -> project.path.
103
+ task_cwd = None
104
+ if routed_target is not None:
105
+ tp = (routed_target.get("path") or "").strip("/")
106
+ task_cwd = project.path / tp if tp else project.path
107
+ logger.info(
108
+ f"Task routed to target "
109
+ f"'{routed_target.get('name') or routed_target.get('path')}' "
110
+ f"(cwd={tp or '.'}): build={target_cmds['build_command']!r}, "
111
+ f"test={target_cmds['test_command']!r}"
112
+ )
113
+
114
+ test_command = task.processor_data.get(test_cmd_key)
115
+ if routed_target is not None and target_cmds["test_command"] is not None:
116
+ test_command = target_cmds["test_command"]
117
+ typecheck_command = (
118
+ task.processor_data.get(typecheck_cmd_key)
119
+ or target_cmds["typecheck_command"]
120
+ )
121
+ task_build_command = target_cmds["build_command"]
122
+ build_timeout = get_setting(project.config, "build", "build_timeout")
123
+ test_timeout = get_setting(project.config, "build", "test_timeout")
124
+
125
+ # Atomic execution: git branch per task (disabled in parallel mode to avoid races)
126
+ can_branch = use_git_branch and self._is_git_repo(project)
127
+ branch_name = f"task/{task.id}" if can_branch else None
128
+ base_branch = None
129
+
130
+ if branch_name:
131
+ base_branch = self._get_current_branch(project)
132
+ if not self._create_task_branch(project, branch_name):
133
+ logger.warning(
134
+ "Failed to create task branch; falling back to file snapshots"
135
+ )
136
+ branch_name = None
137
+
138
+ # Untracked files present BEFORE the task, so revert can clean only the
139
+ # orphans this task creates without touching pre-existing untracked work.
140
+ untracked_before = (
141
+ self._untracked_files(project) if self._is_git_repo(project) else set()
142
+ )
143
+
144
+ # Fallback: file snapshots if git branching isn't available
145
+ snapshot = None
146
+ if not branch_name:
147
+ snapshot = self._snapshot_files(project, target_files)
148
+
149
+ # Seed the first attempt with the baseline test failures so a fix task
150
+ # debugs against the REAL errors from the start, not blindly. Empty on a
151
+ # green baseline -> error_logs stays None and attempt 0 uses the normal
152
+ # task template (unchanged behavior).
153
+ seed_output = getattr(project, "baseline_test_output", "") or ""
154
+ error_logs = (
155
+ f"The project's test suite is currently failing. Fix the failures "
156
+ f"below (focus on this task's files):\n{seed_output[:4000]}"
157
+ if seed_output.strip()
158
+ else None
159
+ )
160
+ prior_errors: List[str] = []
161
+ # Count anchored-edit application failures so we can fall back to a
162
+ # full-file rewrite when SEARCH/REPLACE keeps not matching (a stall that
163
+ # otherwise makes no progress across attempts).
164
+ apply_failures = 0
165
+ # Build the symbol graph now (idempotent, lazy): it isn't constructed at
166
+ # project registration anymore, and task execution is the first consumer.
167
+ try:
168
+ project.topography.initialize()
169
+ except Exception as e:
170
+ logger.warning(f"Topography init failed (continuing without graph): {e}")
171
+ resolver = ErrorResolver(project.path, project.topography.graph)
172
+ # Every in-root path the LLM actually wrote, across all attempts. Commit
173
+ # exactly these (plus declared targets) so an out-of-scope-but-valid
174
+ # edit isn't applied-then-orphaned by staging only the declared files.
175
+ edited_files: set = set()
176
+
177
+ # Optional adversarial critic (independent second component): reviews each
178
+ # candidate edit before it is applied and can force a regeneration with
179
+ # concrete objections. Off by default. Bounded so it defers to the real
180
+ # gates after a few rejections rather than starving the attempt loop.
181
+ critic_enabled = self._critic_enabled_for(project, task)
182
+ critic_max_rejections = get_setting(
183
+ project.config, "orchestrator", "critic_max_rejections"
184
+ )
185
+ critic_rejections = 0
186
+
187
+ # Spec-as-tests (opt-in): generate a failing test from the acceptance
188
+ # criteria BEFORE implementation, written under .orchestrator/spec_tests/
189
+ # (outside the project suite, so it never flips the integration-gate
190
+ # baseline). After the task's gates pass, it is run scoped and must now
191
+ # pass (red -> green). Advisory unless spec_as_tests_block.
192
+ spec_test_path = self._maybe_generate_spec_test(project, task)
193
+ spec_test_block = get_setting(
194
+ project.config, "orchestrator", "spec_as_tests_block"
195
+ )
196
+
197
+ # The most recent attempt awaiting an outcome. Recorded as a failure at
198
+ # the top of the next iteration (we only loop again when an attempt
199
+ # failed) and at loop exit; recorded as a success at the success seams.
200
+ pending_attempt: Optional[dict] = None
201
+ # Baseline full-suite failure count (from analysis), so a RED baseline
202
+ # doesn't reject every task. The test gate then accepts an attempt that
203
+ # leaves the suite no worse (failures <= baseline), letting a multi-failure
204
+ # project be reduced incrementally instead of demanding one task fix the
205
+ # whole suite. 0 (a green baseline) keeps the gate strictly green-only.
206
+ baseline_failures = int(getattr(project, "baseline_test_failures", 0) or 0)
207
+ if routed_target is not None:
208
+ # Never apply the top-level (e.g. core) baseline to a DIFFERENT
209
+ # target's gate. Use that target's own measured baseline if present,
210
+ # else 0 (strict green-only) — far safer than inheriting core's count.
211
+ per_target = getattr(project, "target_baselines", None) or {}
212
+ key = routed_target.get("name") or routed_target.get("path")
213
+ baseline_failures = int(per_target.get(key, 0) or 0)
214
+ _selector = getattr(project, "model_selector", None)
215
+ track_models = (
216
+ _selector is not None
217
+ and _selector.enabled
218
+ and hasattr(project.llm_client, "task_cost")
219
+ )
220
+
221
+ # Whole-project structural map (every file + its symbols). Computed once
222
+ # — it is identical across attempts — so the model edits with the entire
223
+ # project's shape in view, not just the target files. ContextBudget
224
+ # trims it first (priority 3) when space is tight.
225
+ topo = getattr(project, "topography", None)
226
+ project_outline = topo.get_project_outline() if topo is not None else ""
227
+
228
+ # Optional, off-by-default agentic pre-edit gathering: when enabled and
229
+ # an MCP manager with tools exists, the model may request bounded MCP
230
+ # tool calls to gather information. The result is prepended to the task
231
+ # context; off → "" and the path below is byte-identical to today.
232
+ mcp_gathered = self._mcp_gather(project, task)
233
+
234
+ for attempt in range(max_retries):
235
+ logger.info(f"Attempt {attempt + 1}/{max_retries} for task {task.id}")
236
+ if pending_attempt is not None:
237
+ self._ledger_record(project, task, pending_attempt, success=False)
238
+ pending_attempt = None
239
+
240
+ code_context = self._get_code_context(
241
+ project, target_files, context_files, task=task
242
+ )
243
+ topo_context = project.topography.get_context_for_task(
244
+ task.description,
245
+ target_files,
246
+ ranker=getattr(project, "semantic_ranker", None),
247
+ exclude_files=self._fully_shown_target_files(project, target_files),
248
+ )
249
+ # Exhaustive external references to the symbols being edited, so a
250
+ # delete/rename/refactor updates every call site in one attempt
251
+ # instead of chasing them one build-error at a time (the dominant
252
+ # failure mode: missing_symbol/wrong_type across attempts).
253
+ reference_sites = project.topography.reference_sites(target_files)
254
+ scratchpad_context = self.scratchpad.format_context(
255
+ files=target_files + context_files,
256
+ tags=[task.category],
257
+ )
258
+
259
+ strategy = task.processor_data.get("strategy", "iterative")
260
+ consensus = task.processor_data.get("consensus_context", "None")
261
+ interface_contracts = task.processor_data.get("interface_contracts", "")
262
+ recent_changes = task.processor_data.get("recent_changes", "")
263
+
264
+ # Budget-aware context allocation using configured token limit
265
+ budget = ContextBudget(max_tokens=context_budget_tokens)
266
+ budget.set("code_context", code_context, priority=1)
267
+ # Correctness-critical: the complete reference set must survive
268
+ # truncation, or a rename/delete misses sites and the build fails.
269
+ budget.set("reference_sites", reference_sites, priority=1, min_lines=0)
270
+ # topo_context is the task-ranked (relevant-first) symbol-graph map
271
+ # that cross-file tasks (delete-all-refs, wire call-sites) need. At
272
+ # priority=3/min_lines=10 it collapsed to ~10 lines on a large repo
273
+ # (the emathy run: "kept 11/9094"), editing blind. Keep it above the
274
+ # truncate-first tier with a real floor so the top-ranked symbols
275
+ # survive; truncation still trims the long tail under pressure.
276
+ budget.set("topo_context", topo_context, priority=2, min_lines=40)
277
+ budget.set("error_logs", error_logs or "", priority=1, min_lines=20)
278
+ budget.set("scratchpad", scratchpad_context, priority=3)
279
+ budget.set("interface_contracts", interface_contracts, priority=2)
280
+ budget.set("recent_changes", recent_changes, priority=2)
281
+ budget.set("consensus_context", consensus, priority=3)
282
+ budget.set("project_outline", project_outline, priority=3, min_lines=0)
283
+ allocated = budget.allocate()
284
+
285
+ full_code_context = allocated["code_context"]
286
+ if allocated["reference_sites"]:
287
+ full_code_context += "\n\n" + allocated["reference_sites"]
288
+ if allocated["topo_context"]:
289
+ full_code_context += "\n\n" + allocated["topo_context"]
290
+ if allocated["recent_changes"]:
291
+ full_code_context += "\n\n" + allocated["recent_changes"]
292
+ if allocated["project_outline"]:
293
+ full_code_context += (
294
+ "\n\n## Project Structure (files and their symbols)\n"
295
+ + allocated["project_outline"]
296
+ )
297
+ if mcp_gathered:
298
+ full_code_context += mcp_gathered
299
+ full_code_context += self._mcp_awareness(project)
300
+
301
+ context_dict = {
302
+ "project": project,
303
+ "task": task,
304
+ "code_context": full_code_context,
305
+ "error_logs": allocated["error_logs"] or None,
306
+ "task.description": task.description,
307
+ "task.target_files": ", ".join(target_files)
308
+ if target_files
309
+ else "None explicitly specified",
310
+ "scratchpad": allocated["scratchpad"],
311
+ "acceptance_criteria": task.acceptance_criteria,
312
+ "consensus_context": allocated["consensus_context"],
313
+ "interface_contracts": allocated["interface_contracts"],
314
+ "strategy": strategy.upper(),
315
+ "invariants": (
316
+ f"Strategy: {strategy.upper()}. Output MUST be syntactically valid. "
317
+ "Provide certainty indicators."
318
+ ),
319
+ # Marks the boundary between the cacheable stable context above
320
+ # and the volatile tail below (see PROMPT_TEMPLATES); the client
321
+ # caches everything before it.
322
+ "cache_breakpoint": CACHE_BREAKPOINT,
323
+ }
324
+
325
+ system_prompt = prompt_manager.format_prompt("system", context_dict)
326
+ # Use the error-correction template whenever failures are known —
327
+ # including a seeded attempt 0 on a red baseline — so the model always
328
+ # sees the actual failures to fix rather than editing blind.
329
+ if error_logs:
330
+ prompt = prompt_manager.format_prompt(
331
+ "error_correction_instruction", context_dict
332
+ )
333
+ else:
334
+ prompt = prompt_manager.format_prompt(
335
+ "task_completion_instruction", context_dict
336
+ )
337
+ # Tool-based edit extraction returns whole-file content via a forced
338
+ # tool call; the SEARCH/REPLACE contract applies only to the plain
339
+ # text-generation path, where the parser expects anchored hunks.
340
+ if not get_setting(project.config, "llm", "use_tools"):
341
+ # After repeated anchor-match failures, switch to a full-file
342
+ # rewrite (no anchoring → always applies), breaking the stall.
343
+ if apply_failures >= 2:
344
+ prompt += FULL_FILE_FALLBACK_INSTRUCTIONS
345
+ else:
346
+ prompt += EDIT_FORMAT_INSTRUCTIONS
347
+
348
+ routed_model = self._select_model(
349
+ project, task, strategy, attempt, max_retries
350
+ )
351
+ try:
352
+ with project.llm_client.track_task(task.id):
353
+ llm_response, aborted, pending_attempt = self._invoke_routed(
354
+ project,
355
+ task,
356
+ prompt,
357
+ system_prompt,
358
+ routed_model,
359
+ attempt,
360
+ track_models,
361
+ )
362
+ except Exception as e:
363
+ msg = f"LLM generation failed: {e}"
364
+ logger.error(msg)
365
+ self._ledger_record(
366
+ project,
367
+ task,
368
+ {
369
+ "model": routed_model
370
+ or getattr(project.llm_client, "model", ""),
371
+ "attempt": attempt,
372
+ "cost_before": 0.0,
373
+ "latency": 0.0,
374
+ "aborted": False,
375
+ },
376
+ success=False,
377
+ )
378
+ self._abort_task(
379
+ project, branch_name, base_branch, snapshot, untracked_before
380
+ )
381
+ return self._fail_task(project, task, msg)
382
+
383
+ if aborted:
384
+ logger.warning(
385
+ f"LLM stream aborted for {task.id}; retrying with stricter instruction."
386
+ )
387
+ error_logs = "ERROR: response was not code. Output ONLY file edits as code blocks with file paths."
388
+ continue
389
+
390
+ certainty = CertaintyScorer.compute_score(llm_response)
391
+ logger.info(f"LLM Certainty Score: {certainty:.2f}")
392
+
393
+ edits, resolve_error = self._resolve_edits(project, llm_response)
394
+ if resolve_error:
395
+ apply_failures += 1
396
+ logger.warning(
397
+ f"Surgical edit could not be applied (#{apply_failures}): "
398
+ f"{resolve_error}"
399
+ )
400
+ # Two strikes -> next attempt is told to emit the whole file
401
+ # (FULL_FILE_FALLBACK_INSTRUCTIONS), which can't miss an anchor.
402
+ if apply_failures >= 2:
403
+ error_logs = (
404
+ "ERROR: anchored SEARCH/REPLACE edits keep failing to "
405
+ "match the file. Output the COMPLETE updated file instead "
406
+ "(full contents in one code block with the file path)."
407
+ )
408
+ else:
409
+ error_logs = (
410
+ "ERROR: a SEARCH/REPLACE edit did not apply cleanly. "
411
+ f"{resolve_error} Re-read the file and emit a corrected "
412
+ "SEARCH block that matches the current content verbatim."
413
+ )
414
+ continue
415
+ edits = self._validate_edit_paths(project, task, edits)
416
+ if not edits:
417
+ logger.warning("No file edits detected in LLM response.")
418
+ else:
419
+ stall_risk = self.stall_detector.push_edit(edits)
420
+ if stall_risk > 0.7:
421
+ logger.warning(f"High stall risk detected ({stall_risk:.2f}).")
422
+ if attempt > 1:
423
+ error_logs = "ERROR: Stalling detected. Try a fundamentally different approach."
424
+ continue
425
+
426
+ validation_failed = False
427
+ for file_path, content in edits.items():
428
+ lang = _detect_language(file_path)
429
+ valid, error = CodeValidator.validate_code(content, language=lang)
430
+ if not valid:
431
+ logger.error(f"Validation failed for {file_path}: {error}")
432
+ error_logs = f"SYNTAX ERROR in {file_path}:\n{error}"
433
+ validation_failed = True
434
+ break
435
+
436
+ if validation_failed:
437
+ continue
438
+
439
+ tamper = self._detect_test_tampering(project, edits)
440
+ if tamper:
441
+ logger.error(f"Test tampering rejected: {tamper}")
442
+ error_logs = (
443
+ "ERROR: test files were weakened. Do not delete "
444
+ "tests, weaken assertions, or add skip/ignore markers "
445
+ "to make the suite pass. Fix the real code instead. "
446
+ f"Detected: {tamper}"
447
+ )
448
+ continue
449
+
450
+ dangling = self._detect_dangling_references(project, edits)
451
+ if dangling:
452
+ logger.warning(f"Incomplete refactor — dangling refs: {dangling}")
453
+ error_logs = (
454
+ "ERROR: this edit removed or renamed a symbol but left "
455
+ "references to it in files you did not change. Update EVERY "
456
+ "one of these call sites in this attempt (do not fix them "
457
+ f"one at a time): {dangling}"
458
+ )
459
+ continue
460
+
461
+ # Independent adversarial critique BEFORE applying. A rejection
462
+ # feeds concrete objections back as the next attempt's context
463
+ # (regenerate), bounded by critic_max_rejections so an
464
+ # over-zealous critic can't starve the loop — after that the edit
465
+ # flows to the authoritative build/test gates. SKIP/APPROVE fall
466
+ # through and apply as normal; off path is unchanged.
467
+ if critic_enabled and critic_rejections < critic_max_rejections:
468
+ verdict = self._run_edit_critic(project, task, edits)
469
+ if verdict.rejected:
470
+ critic_rejections += 1
471
+ logger.warning(
472
+ f"Adversarial critic rejected the edit on attempt "
473
+ f"{attempt + 1} ({critic_rejections}/"
474
+ f"{critic_max_rejections}): {verdict.objections}"
475
+ )
476
+ error_logs = self._build_critic_error_context(
477
+ verdict.objections
478
+ )
479
+ continue
480
+
481
+ self._apply_edits(project, edits)
482
+ self._run_formatters(project, edits.keys())
483
+ edited_files.update(edits.keys())
484
+
485
+ # Whether an OBJECTIVE compile/type gate passed this attempt. A green
486
+ # build or typecheck IS verification, so a typecheck-only target (a
487
+ # frontend with no unit tests) must NOT then also be gated on the
488
+ # LLM's self-reported certainty — that wrongly rejects good edits.
489
+ gate_verified = False
490
+ build_cmd = task_build_command
491
+ if build_cmd:
492
+ success, output = self._run_command(
493
+ project, build_cmd, timeout=build_timeout, cwd=task_cwd
494
+ )
495
+ if not success:
496
+ logger.warning(f"Build failed on attempt {attempt + 1}")
497
+ locations = resolver.resolve_errors(output)
498
+ attributed_error = resolver.format_for_llm(locations)
499
+ classified = format_classified_error(output)
500
+ error_logs = self._build_error_context(
501
+ prior_errors, attempt, output, classified, attributed_error
502
+ )
503
+ continue
504
+ gate_verified = True
505
+
506
+ if typecheck_command:
507
+ success, output = self._run_command(
508
+ project, typecheck_command, timeout=build_timeout, cwd=task_cwd
509
+ )
510
+ if not success:
511
+ logger.warning(f"Type check failed on attempt {attempt + 1}")
512
+ locations = resolver.resolve_errors(output)
513
+ attributed_error = resolver.format_for_llm(locations)
514
+ classified = format_classified_error(output)
515
+ error_logs = self._build_error_context(
516
+ prior_errors, attempt, output, classified, attributed_error
517
+ )
518
+ continue
519
+ gate_verified = True
520
+
521
+ if test_command:
522
+ success, output = self._run_command(
523
+ project, test_command, timeout=test_timeout, cwd=task_cwd
524
+ )
525
+ accepted, post_failures = self._gate_accepts(
526
+ success, output, baseline_failures
527
+ )
528
+ if accepted:
529
+ if success:
530
+ logger.info("Tests passed successfully.")
531
+ else:
532
+ logger.info(
533
+ f"Suite still red ({post_failures} failing) but not "
534
+ f"worse than the baseline ({baseline_failures}); "
535
+ "accepting incremental progress."
536
+ )
537
+ if post_failures < baseline_failures:
538
+ # Ratchet the baseline down so a later task can't
539
+ # re-introduce what this one fixed.
540
+ project.baseline_test_failures = post_failures
541
+ baseline_failures = post_failures
542
+ acc_ok, acc_output = self._verify_acceptance(
543
+ project,
544
+ task,
545
+ verify_acceptance,
546
+ llm_acceptance_judge,
547
+ test_timeout,
548
+ cwd=task_cwd,
549
+ )
550
+ if not acc_ok:
551
+ logger.warning(
552
+ f"Acceptance criteria not met on attempt {attempt + 1}."
553
+ )
554
+ classified = format_classified_error(acc_output)
555
+ error_logs = self._build_acceptance_error_context(
556
+ prior_errors, attempt, task, classified
557
+ )
558
+ continue
559
+ # Spec-as-tests: the pre-written failing test must now pass.
560
+ spec_status, spec_detail = self._run_spec_test(
561
+ project, spec_test_path, test_timeout
562
+ )
563
+ if spec_status == "red" and spec_test_block:
564
+ logger.warning(
565
+ f"Spec test still fails on attempt {attempt + 1}; "
566
+ "implementation does not satisfy the spec."
567
+ )
568
+ error_logs = self._build_acceptance_error_context(
569
+ prior_errors,
570
+ attempt,
571
+ task,
572
+ format_classified_error(spec_detail),
573
+ )
574
+ continue
575
+ if spec_status == "red":
576
+ logger.warning(
577
+ f"Spec test for {task.id} still fails (advisory; not "
578
+ "blocking). The implementation may not fully satisfy "
579
+ "the acceptance criterion."
580
+ )
581
+ task.processor_data.setdefault("spec_test_gaps", []).append(
582
+ spec_detail[:200]
583
+ )
584
+ self._ledger_record(project, task, pending_attempt, success=True)
585
+ self._cache_store(
586
+ project,
587
+ system_prompt,
588
+ prompt,
589
+ llm_response,
590
+ pending_attempt["model"],
591
+ )
592
+ pending_attempt = None
593
+ self._record_success(task, target_files)
594
+ # Persist status BEFORE committing so the source markdown's
595
+ # status:completed is part of the task commit and survives the
596
+ # merge; otherwise the next task's checkout discards it.
597
+ project.task_manager.update_task_status(task.id, "completed")
598
+ self._commit_task(
599
+ project,
600
+ branch_name,
601
+ base_branch,
602
+ task,
603
+ sorted(set(target_files) | edited_files),
604
+ )
605
+ return self._complete_task(
606
+ project, task, "Task completed and tests passed.", output
607
+ )
608
+ else:
609
+ logger.warning(f"Tests failed on attempt {attempt + 1}.")
610
+ locations = resolver.resolve_errors(output)
611
+ attributed_error = resolver.format_for_llm(locations)
612
+ classified = format_classified_error(output)
613
+ error_logs = self._build_error_context(
614
+ prior_errors, attempt, output, classified, attributed_error
615
+ )
616
+ elif certainty < certainty_threshold and not gate_verified:
617
+ # No test gate AND no compile/type gate ran, so completion rests
618
+ # entirely on the LLM's word. With low certainty that's not enough
619
+ # to trust: force a retry (which escalates to surgical once
620
+ # attempts run out) instead of silently accepting unverified code.
621
+ # A green build/typecheck (gate_verified) is objective proof and
622
+ # bypasses this — the common frontend case (typecheck, no tests).
623
+ logger.warning(
624
+ f"No tests/build verified this and certainty {certainty:.2f} < "
625
+ f"{certainty_threshold:.2f}; refusing silent completion."
626
+ )
627
+ error_logs = (
628
+ "ERROR: no tests verify this change and the response "
629
+ "expressed low certainty. Provide a higher-confidence, "
630
+ "syntactically valid edit with explicit verification."
631
+ )
632
+ continue
633
+ else:
634
+ acc_ok, acc_output = self._verify_acceptance(
635
+ project,
636
+ task,
637
+ verify_acceptance,
638
+ llm_acceptance_judge,
639
+ test_timeout,
640
+ cwd=task_cwd,
641
+ )
642
+ if not acc_ok:
643
+ logger.warning(
644
+ f"Acceptance criteria not met on attempt {attempt + 1}."
645
+ )
646
+ classified = format_classified_error(acc_output)
647
+ error_logs = self._build_acceptance_error_context(
648
+ prior_errors, attempt, task, classified
649
+ )
650
+ continue
651
+ self._ledger_record(project, task, pending_attempt, success=True)
652
+ self._cache_store(
653
+ project,
654
+ system_prompt,
655
+ prompt,
656
+ llm_response,
657
+ pending_attempt["model"],
658
+ )
659
+ pending_attempt = None
660
+ self._record_success(task, target_files)
661
+ # Persist status BEFORE committing so status:completed rides into
662
+ # the task commit and survives the merge (see the tests-passed
663
+ # path above).
664
+ project.task_manager.update_task_status(task.id, "completed")
665
+ self._commit_task(
666
+ project,
667
+ branch_name,
668
+ base_branch,
669
+ task,
670
+ sorted(set(target_files) | edited_files),
671
+ )
672
+ return self._complete_task(
673
+ project, task, "Task completed (no tests run).", llm_response
674
+ )
675
+
676
+ # The final attempt fell through without succeeding; record it before
677
+ # escalation/failure so its model gets the failing outcome it earned.
678
+ if pending_attempt is not None:
679
+ self._ledger_record(project, task, pending_attempt, success=False)
680
+ pending_attempt = None
681
+
682
+ # Strategy escalation: if current strategy failed, try one more attempt
683
+ # with "surgical". Guarded by _depth so escalation can never recurse more
684
+ # than once, even if the strategy-selection logic changes later.
685
+ current_strategy = task.processor_data.get("strategy", "iterative")
686
+ if current_strategy != "surgical" and _depth < 1:
687
+ logger.info(
688
+ f"Escalating strategy from {current_strategy} to surgical for final attempt"
689
+ )
690
+ self._abort_task(
691
+ project, branch_name, base_branch, snapshot, untracked_before
692
+ )
693
+
694
+ task.processor_data["strategy"] = "surgical"
695
+ task.processor_data["invariants"] = (
696
+ "ESCALATED: Previous strategy failed. Use SURGICAL approach: "
697
+ "make the smallest possible change to fix the immediate error. "
698
+ "Do not refactor or restructure. Minimal, targeted fix only."
699
+ )
700
+ # Recursive single attempt with surgical strategy
701
+ return self.execute(
702
+ task, project, use_git_branch=use_git_branch, _depth=_depth + 1
703
+ )
704
+ elif _depth >= 1:
705
+ logger.warning(
706
+ f"Strategy escalation blocked at depth {_depth}: already exhausted all strategies"
707
+ )
708
+
709
+ logger.warning(
710
+ f"Task {task.id} failed after all attempts including escalation. Reverting."
711
+ )
712
+ self._abort_task(project, branch_name, base_branch, snapshot, untracked_before)
713
+ self.scratchpad.record(
714
+ category="pitfall",
715
+ discovery=(
716
+ f"Failed after {max_retries} attempts + escalation: "
717
+ f"{error_logs[:200] if error_logs else 'unknown'}"
718
+ ),
719
+ task_id=task.id,
720
+ files=target_files,
721
+ )
722
+ return self._fail_task(
723
+ project,
724
+ task,
725
+ f"Task failed after {max_retries} attempts + escalation.",
726
+ error_logs,
727
+ )