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
misterdev/agent.py ADDED
@@ -0,0 +1,2166 @@
1
+ import concurrent.futures
2
+ import re
3
+ import subprocess
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional, Dict, Any
7
+
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.prompt import Prompt
12
+
13
+ from misterdev.core.execution.registry import ProjectRegistry
14
+ from misterdev.core.modes import (
15
+ BuildMode,
16
+ BuildFlags,
17
+ parse_flags,
18
+ resolve_mode,
19
+ )
20
+ from misterdev.core.planning.assessment import (
21
+ ProjectAssessment,
22
+ HealthCheck,
23
+ )
24
+ from misterdev.core.context.scratchpad import Scratchpad
25
+ from misterdev.core.planning.decomposer import (
26
+ decompose_spec,
27
+ topological_sort,
28
+ format_plan,
29
+ )
30
+ from misterdev.core.verification.validator import ValidationResult
31
+ from misterdev.core.verification.gatekeeper import GateKeeper
32
+ from misterdev.core.gitcmd import run_git
33
+ from misterdev.core.planning.sovereign import (
34
+ StrategyOptimizer,
35
+ RealTimeAligner,
36
+ ABMCTSPlanner,
37
+ EphemeralCodeManager,
38
+ ProbeGenerator,
39
+ )
40
+ from misterdev.core.planning.metacognition import SessionAuditor
41
+ from misterdev.core.context.contracts import ContractRegistry
42
+ from misterdev.core.verification.preflight import PreflightValidator
43
+ from misterdev.core.execution.progress import (
44
+ ProgressTracker,
45
+ compute_task_hash,
46
+ )
47
+ from misterdev.core.context.change_tracker import ChangeTracker
48
+ from misterdev.core.reporting.report import BuildReport
49
+ from misterdev.core.execution.project import Project
50
+ from misterdev.core.models import Task
51
+ from misterdev.analyzers.project_analyzer import (
52
+ analyze_project,
53
+ )
54
+ from misterdev.core.planning.advisor import recommend_work
55
+ from misterdev.llm.client import BudgetExceededError
56
+ from misterdev.task_executors.markdown_plan_executor import (
57
+ MarkdownPlanExecutor,
58
+ )
59
+ from misterdev.agent_helpers import (
60
+ ProgressReporter,
61
+ _WorktreeProjectView,
62
+ _apply_budget_ceiling,
63
+ _budget_exhausted,
64
+ _check_golden_config,
65
+ _combine_commands,
66
+ _warn_if_baseline_broken,
67
+ _warn_if_no_test_gate,
68
+ )
69
+ from misterdev.config import get_setting
70
+ from misterdev.logging_setup import setup_logger
71
+
72
+ logger = setup_logger(__name__)
73
+ console = Console()
74
+
75
+ MAX_CONSECUTIVE_FAILURES = 3
76
+
77
+ # Safety backstop for "auto" convergence: the loop normally stops earlier on a
78
+ # green gate, budget exhaustion, or no-progress, but this bounds a pathological
79
+ # run that keeps producing genuinely different (yet still failing) fix tasks.
80
+ CONVERGENCE_CEILING = 25
81
+
82
+
83
+ class ProjectOrchestrator:
84
+ """Main orchestrator with Sovereign Grounded workflow."""
85
+
86
+ def __init__(self):
87
+ self.registry = ProjectRegistry()
88
+ self.last_build_succeeded = True
89
+
90
+ def scan_directory(self, path: str | Path):
91
+ self.registry.discover_projects(path)
92
+
93
+ def list_projects(self) -> Dict[str, Any]:
94
+ return self.registry.list_projects()
95
+
96
+ def get_project_status(self, project_path: str | Path) -> Dict[str, Any]:
97
+ project = self.registry.get_project(project_path)
98
+ if not project:
99
+ try:
100
+ project = self.registry.register_project(project_path)
101
+ except Exception as e:
102
+ logger.error(f"Failed to load project at {project_path}: {e}")
103
+ return {"error": f"Project load failed: {e}"}
104
+ project.task_manager.discover_tasks()
105
+ return {
106
+ "name": project.name,
107
+ "path": str(project.path),
108
+ "description": project.description,
109
+ "tasks": [
110
+ {"id": t.id, "status": t.status, "description": t.description[:100]}
111
+ for t in project.task_manager.tasks.values()
112
+ ],
113
+ }
114
+
115
+ def run_project(
116
+ self,
117
+ project_path: str | Path,
118
+ dry_run: bool = False,
119
+ skip_preflight: bool = False,
120
+ force: bool = False,
121
+ status: bool = False,
122
+ ):
123
+ """Run pending devplan tasks with dependency-aware orchestration.
124
+
125
+ Unlike build(), this executes a pre-written devplan: it skips the
126
+ analysis/spec/decomposition/gate phases but adds topological
127
+ ordering, progress-based crash recovery, contract injection, scratchpad
128
+ learning, and change tracking around the existing markdown tasks.
129
+ """
130
+ project = self._get_or_register(project_path)
131
+ if not project:
132
+ return
133
+ _check_golden_config(project.config)
134
+ if project.env_manager:
135
+ project.env_manager.setup()
136
+ project.task_manager.discover_tasks()
137
+ pending = project.task_manager.get_pending_tasks()
138
+ if not pending:
139
+ logger.info(f"No pending tasks for {project.name}.")
140
+ return
141
+ tasks = topological_sort(pending)
142
+
143
+ if not skip_preflight:
144
+ issues = PreflightValidator().validate(tasks, project.path)
145
+ for issue in issues:
146
+ log = logger.error if issue.severity == "error" else logger.warning
147
+ log(f"Preflight {issue.severity}: {issue.task_id}: {issue.message}")
148
+ if PreflightValidator.has_errors(issues):
149
+ logger.error(
150
+ "Preflight validation failed; aborting. Use --skip-preflight to override."
151
+ )
152
+ return
153
+
154
+ if dry_run:
155
+ self._print_execution_plan(tasks)
156
+ return
157
+
158
+ if status:
159
+ self._print_rerun_status(
160
+ tasks, ProgressTracker(project.path), project.path, force
161
+ )
162
+ return
163
+
164
+ scratchpad = Scratchpad()
165
+ contracts = ContractRegistry(project.path)
166
+ progress = ProgressTracker(project.path)
167
+ changes = ChangeTracker(project.path)
168
+ strategy_optimizer = StrategyOptimizer()
169
+ executor = MarkdownPlanExecutor(scratchpad=scratchpad)
170
+ lang = (project.config.get("language") or "python").lower()
171
+ max_consecutive_failures = get_setting(
172
+ project.config, "orchestrator", "max_consecutive_failures"
173
+ )
174
+
175
+ completed_ids = set(progress.completed)
176
+ failed_ids: set[str] = set()
177
+ consecutive_failures = 0
178
+ if completed_ids:
179
+ logger.info(f"Resuming run: {len(completed_ids)} tasks already completed")
180
+ logger.info(f"Running {len(tasks)} pending tasks for {project.name}.")
181
+
182
+ reporter = ProgressReporter(len(tasks))
183
+ remaining = list(tasks)
184
+ aborted = False
185
+ wave = 0
186
+ while remaining and not aborted:
187
+ ready, still_waiting = [], []
188
+ for task in remaining:
189
+ if not force and not progress.needs_rerun(
190
+ task.id, compute_task_hash(task, project.path)
191
+ ):
192
+ completed_ids.add(task.id)
193
+ continue
194
+ if progress.is_done(task.id):
195
+ logger.info(
196
+ f"Task {task.id} previously completed but inputs changed; re-running."
197
+ )
198
+ if any(d in failed_ids for d in task.dependencies):
199
+ failed_ids.add(task.id)
200
+ logger.warning(f"Skipping {task.id}: a dependency failed.")
201
+ continue
202
+ if any(d not in completed_ids for d in task.dependencies):
203
+ still_waiting.append(task)
204
+ continue
205
+ ready.append(task)
206
+
207
+ if not ready:
208
+ if still_waiting:
209
+ logger.error(
210
+ f"Dependency deadlock; unresolved tasks: {[t.id for t in still_waiting]}"
211
+ )
212
+ break
213
+
214
+ wave += 1
215
+ reporter.start_wave(wave, [t.id for t in ready])
216
+ for task in ready:
217
+ reporter.start_task(task.id, task.title or task.description[:50])
218
+ try:
219
+ self._inject_task_context(
220
+ task, contracts, changes, strategy_optimizer, project
221
+ )
222
+ result = executor.execute(task, project)
223
+ task.execution_history.append(result)
224
+ except BudgetExceededError as e:
225
+ # Terminal (budget spent or account out of credits): stop the
226
+ # whole run cleanly rather than crash or churn the next tasks.
227
+ logger.warning(f"Halting run: {e}")
228
+ aborted = True
229
+ break
230
+ except Exception as e:
231
+ logger.error(f"Task {task.id} raised: {e}")
232
+ result = None
233
+
234
+ succeeded = result is not None and result.status == "completed"
235
+ reporter.end_task(task.id, succeeded)
236
+ if succeeded:
237
+ completed_ids.add(task.id)
238
+ progress.mark_completed(
239
+ task.id, compute_task_hash(task, project.path)
240
+ )
241
+ consecutive_failures = 0
242
+ modified = task.files_to_modify + task.files_to_create
243
+ if modified:
244
+ contracts.extract_contracts(
245
+ task.id,
246
+ modified,
247
+ project.path,
248
+ project.llm_client,
249
+ language=lang,
250
+ )
251
+ changes.record_task_changes(task.id, modified)
252
+ else:
253
+ failed_ids.add(task.id)
254
+ progress.mark_failed(task.id)
255
+ consecutive_failures += 1
256
+ if consecutive_failures >= max_consecutive_failures:
257
+ logger.error("Aborting run: too many consecutive failures.")
258
+ aborted = True
259
+ break
260
+
261
+ remaining = still_waiting
262
+
263
+ reporter.summary()
264
+
265
+ def _inject_task_context(
266
+ self, task, contracts, changes, strategy_optimizer, project
267
+ ) -> None:
268
+ """Populate a task's processor_data with contracts, recent changes, and strategy."""
269
+ task.processor_data["interface_contracts"] = contracts.get_contracts_for_task(
270
+ task.dependencies
271
+ )
272
+ task.processor_data["recent_changes"] = changes.get_recent_changes_for_files(
273
+ task.files_to_modify + task.files_to_create
274
+ )
275
+ task.processor_data["strategy"] = strategy_optimizer.select_best_strategy(
276
+ task.description, task.category, "", project.llm_client
277
+ )
278
+
279
+ def _print_execution_plan(self, tasks: list[Task]) -> None:
280
+ """Print the dependency-ordered wave plan without executing anything."""
281
+ completed: set[str] = set()
282
+ remaining = list(tasks)
283
+ wave = 0
284
+ console.print(f"\n[bold]Execution Plan (dry-run): {len(tasks)} tasks[/]")
285
+ while remaining:
286
+ ready = [
287
+ t for t in remaining if all(d in completed for d in t.dependencies)
288
+ ]
289
+ if not ready:
290
+ console.print(
291
+ f"[red]Dependency deadlock among: {[t.id for t in remaining]}[/]"
292
+ )
293
+ break
294
+ wave += 1
295
+ console.print(f"\n[bold cyan]Wave {wave}[/] ({len(ready)} parallel):")
296
+ for t in ready:
297
+ deps = f" -> depends on {t.dependencies}" if t.dependencies else ""
298
+ console.print(
299
+ f" [{t.id}] {t.title or t.description[:50]} ({t.complexity}, {t.category}){deps}"
300
+ )
301
+ completed.add(t.id)
302
+ remaining = [t for t in remaining if t.id not in completed]
303
+ console.print(f"\n[dim]Total: {len(tasks)} tasks, {wave} waves.[/]\n")
304
+
305
+ def _print_rerun_status(
306
+ self, tasks: list[Task], progress, project_path, force: bool
307
+ ) -> None:
308
+ """Show which tasks would run vs skip, based on content hashes."""
309
+ console.print(f"\n[bold]Task status: {len(tasks)} tasks[/]")
310
+ run = 0
311
+ for t in tasks:
312
+ rerun = force or progress.needs_rerun(
313
+ t.id, compute_task_hash(t, project_path)
314
+ )
315
+ if rerun:
316
+ run += 1
317
+ reason = (
318
+ "forced"
319
+ if force
320
+ else ("changed" if progress.is_done(t.id) else "pending")
321
+ )
322
+ console.print(f" [yellow]RUN [/] {t.id} ({reason})")
323
+ else:
324
+ console.print(f" [green]SKIP[/] {t.id} (unchanged, completed)")
325
+ console.print(f"\n[dim]{run} would run, {len(tasks) - run} would skip.[/]\n")
326
+
327
+ def run_task(self, project_path: str | Path, task_id: str):
328
+ """Runs a specific task for a given project."""
329
+ project = self._get_or_register(project_path)
330
+ if not project:
331
+ return
332
+ if project.env_manager:
333
+ project.env_manager.setup()
334
+ project.task_manager.discover_tasks()
335
+ task = project.task_manager.tasks.get(task_id)
336
+ if not task:
337
+ logger.error(f"Task {task_id} not found in {project.name}.")
338
+ return
339
+ # Inject contracts from this task's already-completed dependencies.
340
+ contracts = ContractRegistry(project.path)
341
+ task.processor_data["interface_contracts"] = contracts.get_contracts_for_task(
342
+ task.dependencies
343
+ )
344
+ executor = MarkdownPlanExecutor()
345
+ try:
346
+ result = executor.execute(task, project)
347
+ task.execution_history.append(result)
348
+ except Exception as e:
349
+ logger.error(f"Task {task_id} raised: {e}")
350
+ project.task_manager.update_task_status(task_id, "failed")
351
+
352
+ def build(self, project_path: str | Path, args: str = "") -> str:
353
+ project = self._get_or_register(project_path)
354
+ if not project:
355
+ return "Error: could not load project"
356
+
357
+ arg_list = args.split() if args else []
358
+ remaining, flags = parse_flags(arg_list)
359
+ prompt = " ".join(remaining)
360
+ mode = resolve_mode(prompt, project.path)
361
+
362
+ logger.info(f"Build started: mode={mode.value}, flags={flags}")
363
+ start_time = datetime.now(timezone.utc)
364
+
365
+ # Refuse to run on a dirty working tree: branch-per-task execution
366
+ # carries and can sweep/revert uncommitted changes, so a second writer
367
+ # (or the user's own in-progress work) would be corrupted. Override with
368
+ # --allow-dirty. Skipped for dry-run (read-only).
369
+ if not flags.dry_run and not flags.allow_dirty:
370
+ dirty = self._working_tree_dirty(project)
371
+ if dirty:
372
+ logger.error("Refusing to build on a dirty working tree.")
373
+ return (
374
+ f"Error: working tree has uncommitted changes ({dirty}). "
375
+ "Commit or stash them first, or pass --allow-dirty to override."
376
+ )
377
+
378
+ # Propagate budget to LLM client
379
+ _apply_budget_ceiling(project.llm_client, flags.budget)
380
+
381
+ # Preflight: fail fast on a retired/misrouted model id before spending
382
+ # the analysis phase on calls that would 404 mid-run.
383
+ if not flags.dry_run:
384
+ ok, detail = project.llm_client.health_check()
385
+ if not ok:
386
+ logger.error(f"Model preflight failed: {detail}")
387
+ return f"Error: {detail}. Set a valid model in config before building."
388
+
389
+ env_activate = self._setup_env(project)
390
+
391
+ # The budget ceiling is a graceful kill-switch, not a crash: a
392
+ # BudgetExceededError can surface from ANY model call (an analysis
393
+ # analyzer, probe discovery, spec generation, decomposition, or a task),
394
+ # so wrap the whole pipeline and degrade to a partial report instead of
395
+ # letting the CLI die with a traceback. (Found by dogfooding: a $2 cap was
396
+ # exhausted by pre-execution analysis+probes+spec and crashed the run.)
397
+ report = None
398
+ try:
399
+ # Phase 1: Analysis
400
+ assessment = self._analyze(project, env_activate)
401
+
402
+ report = BuildReport(mode, project.name, assessment, start_time)
403
+ report.health_before = assessment.health.model_copy()
404
+ _warn_if_baseline_broken(assessment, report)
405
+ _warn_if_no_test_gate(assessment, project, report)
406
+
407
+ return self._run_pipeline(
408
+ project, prompt, mode, flags, assessment, env_activate, report
409
+ )
410
+ except BudgetExceededError as e:
411
+ return self._halt_on_budget(project, report, e)
412
+
413
+ def _halt_on_budget(
414
+ self, project: Project, report: Optional[BuildReport], error: Exception
415
+ ) -> str:
416
+ """Degrade a budget-exhausted run to a partial report (never a traceback).
417
+
418
+ Records the halt, finalizes whatever work completed, and returns the
419
+ report markdown. When the cap is hit before the report exists (during
420
+ analysis), returns a concise message instead.
421
+ """
422
+ self.last_build_succeeded = False
423
+ logger.error(f"Build halted by budget ceiling: {error}")
424
+ if report is None:
425
+ return (
426
+ f"Build halted: {error}. The budget ceiling stopped the run during "
427
+ "analysis, before any task executed. Raise --budget to proceed."
428
+ )
429
+ report.key_decisions.append(f"Halted by budget ceiling: {error}")
430
+ report.finalize()
431
+ usage = project.llm_client.cumulative_usage
432
+ report.llm_calls = usage.call_count
433
+ report.llm_tokens = usage.total_tokens
434
+ report.llm_prompt_tokens = getattr(usage, "prompt_tokens", 0)
435
+ report.llm_completion_tokens = getattr(usage, "completion_tokens", 0)
436
+ report.llm_cache_read_tokens = getattr(usage, "cache_read_tokens", 0)
437
+ report.llm_cost = usage.estimated_cost
438
+ report.save(project.path)
439
+ return report.to_markdown()
440
+
441
+ def interactive_plan(self, project_path: str | Path, args: str = "") -> str:
442
+ """Analyze the project, recommend work, and compose a plan with the user.
443
+
444
+ The entry point for a plain `misterdev` invocation: instead
445
+ of a predefined devplan, it reads the live project state, proposes
446
+ ranked work items, lets the user choose (or type their own goal), then
447
+ composes and confirms the plan before executing.
448
+ """
449
+ project = self._get_or_register(project_path)
450
+ if not project:
451
+ return "Error: could not load project"
452
+
453
+ _, flags = parse_flags(args.split() if args else [])
454
+ start_time = datetime.now(timezone.utc)
455
+ _apply_budget_ceiling(project.llm_client, flags.budget)
456
+
457
+ if not flags.allow_dirty:
458
+ dirty = self._working_tree_dirty(project)
459
+ if dirty:
460
+ console.print(
461
+ f"[red]Working tree has uncommitted changes ({dirty}).[/] "
462
+ "Commit or stash first, or pass --allow-dirty."
463
+ )
464
+ return f"Error: dirty working tree ({dirty})."
465
+
466
+ ok, detail = project.llm_client.health_check()
467
+ if not ok:
468
+ console.print(f"[red]Model preflight failed:[/] {detail}")
469
+ return f"Error: {detail}. Set a valid model in config before building."
470
+
471
+ env_activate = self._setup_env(project)
472
+
473
+ report = None
474
+ try:
475
+ console.print(f"[bold]Analyzing[/] {project.name} ...")
476
+ assessment = self._analyze(project, env_activate)
477
+ console.print(
478
+ Panel(assessment.summary(), title="Current state", expand=False)
479
+ )
480
+
481
+ recs = recommend_work(assessment, project.llm_client)
482
+ goal, mode = self._choose_goal(recs)
483
+ if goal is None:
484
+ return "Cancelled: no work selected."
485
+
486
+ report = BuildReport(mode, project.name, assessment, start_time)
487
+ report.health_before = assessment.health.model_copy()
488
+ _warn_if_baseline_broken(assessment, report)
489
+ _warn_if_no_test_gate(assessment, project, report)
490
+ return self._run_pipeline(
491
+ project,
492
+ goal,
493
+ mode,
494
+ flags,
495
+ assessment,
496
+ env_activate,
497
+ report,
498
+ confirm_plan=True,
499
+ )
500
+ except BudgetExceededError as e:
501
+ return self._halt_on_budget(project, report, e)
502
+
503
+ def _choose_goal(self, recs: list) -> tuple[Optional[str], BuildMode]:
504
+ """Present recommendations and return the chosen (goal, mode).
505
+
506
+ Returns (None, _) if the user quits. A free-text goal resolves its own
507
+ mode; a picked recommendation carries the advisor's work_type.
508
+ """
509
+ if recs:
510
+ console.print("\n[bold]Recommended work:[/]")
511
+ for i, r in enumerate(recs, 1):
512
+ console.print(
513
+ f" [cyan]{i}[/]. {r.title} [dim]({r.work_type}) — {r.rationale}[/]"
514
+ )
515
+ console.print("\nEnter a number to pick, type your own goal, or 'q' to quit.")
516
+ choice = Prompt.ask("Goal").strip()
517
+ if not choice or choice.lower() in ("q", "quit"):
518
+ return None, BuildMode.SMART
519
+ if choice.isdigit() and recs:
520
+ idx = int(choice) - 1
521
+ if 0 <= idx < len(recs):
522
+ r = recs[idx]
523
+ return r.title, self._WORK_TYPE_MODES.get(r.work_type, BuildMode.SMART)
524
+ console.print("[yellow]Out of range; treating input as a goal.[/]")
525
+ return choice, resolve_mode(choice, Path("."))
526
+
527
+ _WORK_TYPE_MODES = {
528
+ "debug": BuildMode.DEBUG,
529
+ "complete": BuildMode.COMPLETE,
530
+ "feature": BuildMode.SMART,
531
+ "refactor": BuildMode.SMART,
532
+ "test": BuildMode.SMART,
533
+ "docs": BuildMode.SMART,
534
+ }
535
+
536
+ def _confirm(self, question: str) -> bool:
537
+ """Ask a yes/no question; defaults to no."""
538
+ return Prompt.ask(f"{question} [y/N]", default="n").strip().lower() in (
539
+ "y",
540
+ "yes",
541
+ )
542
+
543
+ def _working_tree_dirty(self, project: Project) -> str:
544
+ """Return a short summary if the git working tree has uncommitted changes.
545
+
546
+ Returns "" when clean or not a git repo. `git status --porcelain`
547
+ already excludes ignored paths, so the orchestrator's own `.orchestrator/`
548
+ cache (gitignored) never counts as dirty.
549
+ """
550
+ if not (Path(project.path) / ".git").exists():
551
+ return ""
552
+ try:
553
+ proc = subprocess.run(
554
+ ["git", "status", "--porcelain"],
555
+ cwd=project.path,
556
+ capture_output=True,
557
+ text=True,
558
+ timeout=30,
559
+ )
560
+ except (subprocess.SubprocessError, OSError) as e:
561
+ logger.warning(f"Could not check working tree status: {e}")
562
+ return ""
563
+ lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
564
+ if not lines:
565
+ return ""
566
+ return f"{len(lines)} file(s), e.g. {lines[0][3:].strip()}"
567
+
568
+ def _run_pipeline(
569
+ self,
570
+ project: Project,
571
+ prompt: str,
572
+ mode: BuildMode,
573
+ flags: BuildFlags,
574
+ assessment: ProjectAssessment,
575
+ env_activate: Optional[str],
576
+ report: BuildReport,
577
+ confirm_plan: bool = False,
578
+ ) -> str:
579
+ """Phases 1.5-6: probes, spec, decompose, (confirm), execute, validate.
580
+
581
+ Shared by build() and interactive_plan(). When confirm_plan is set, the
582
+ composed plan is shown and the user is asked to approve it before any
583
+ task executes.
584
+ """
585
+ _check_golden_config(project.config)
586
+ # Make the analysis baseline failure count available to the per-task test
587
+ # gate so a RED baseline doesn't reject every task: the gate then accepts a
588
+ # task that leaves the suite no worse, letting a multi-failure project be
589
+ # fixed incrementally. 0 on a green/unknown baseline keeps the gate strict.
590
+ project.baseline_test_failures = int(
591
+ getattr(assessment.health, "test_failures", 0) or 0
592
+ )
593
+ # Also surface the baseline failure OUTPUT so a fix task can see the real
594
+ # failures on its FIRST attempt (instead of editing blind and only learning
595
+ # what broke on retry). Empty on a green baseline.
596
+ project.baseline_test_output = (
597
+ getattr(assessment.health, "test_output", "") or ""
598
+ if project.baseline_test_failures
599
+ else ""
600
+ )
601
+ # Record the pre-build HEAD so the optional goal-completion check can diff
602
+ # the whole build's work (committed task commits + working tree) against
603
+ # it. Best-effort: None outside a git repo or on error, which the check
604
+ # treats as "no diff" and degrades to a summary-only judgment.
605
+ goal_check_base = self._capture_head(project)
606
+ # Spec-as-tests, when enabled, is wired per-task in the executor: the
607
+ # generated failing test is written under .orchestrator/spec_tests/
608
+ # (outside the project suite, so it never flips the integration-gate
609
+ # baseline) and run scoped after the task's own gates pass. Nothing to do
610
+ # at the pipeline level.
611
+ # Sovereign Phase 1.5: Empirical Probes (only for SMART/CREATE modes).
612
+ # Best-effort: probe discovery must never crash the build, so any
613
+ # failure here degrades to no verified facts rather than aborting. Opt out
614
+ # via orchestrator.enable_probes for a cheaper/faster run (probes spend an
615
+ # LLM call plus ephemeral script runs before any task executes).
616
+ verified_facts = ""
617
+ probes_on = get_setting(project.config, "orchestrator", "enable_probes")
618
+ if probes_on and mode in (BuildMode.SMART, BuildMode.CREATE):
619
+ logger.info("Phase 1.5: Empirical Probe Discovery")
620
+ try:
621
+ probe_gen = ProbeGenerator(project.llm_client)
622
+ with EphemeralCodeManager(project.path) as ephemeral:
623
+ probes = probe_gen.generate_probes(prompt, assessment.summary())
624
+ probe_findings = []
625
+ for p in probes:
626
+ success, output = ephemeral.run_ephemeral_script(
627
+ p.get("script", ""),
628
+ name=f"probe_{p.get('name', 'unknown')}",
629
+ )
630
+ probe_findings.append(
631
+ f"Probe: {p.get('name', '?')} -> {output}"
632
+ )
633
+ verified_facts = "\n".join(probe_findings)
634
+ except Exception as e:
635
+ logger.warning(f"Probe discovery failed (non-fatal): {e}")
636
+ report.degraded_subsystems.append(f"Empirical probes: {e}")
637
+
638
+ # Verify completeness claims against the real source before they shape the
639
+ # spec or the task list, so deliberate design (graceful-degradation,
640
+ # platform no-ops, parity shims) is not planned as work. Best-effort and
641
+ # conservative: only claims the verifier refutes with evidence are dropped.
642
+ self._verify_completeness_claims(project, assessment, report)
643
+
644
+ # Phase 2: Generate Spec
645
+ spec = self._generate_spec(
646
+ mode, prompt, assessment, project, facts=verified_facts
647
+ )
648
+
649
+ # Sovereign enhancements (metacognition, AB-MCTS) are best-effort: they
650
+ # refine the spec but must not crash the build, so each degrades to the
651
+ # current spec on failure rather than aborting before any work is done.
652
+ auditor = SessionAuditor(project.path, project.llm_client)
653
+ try:
654
+ lessons = auditor.get_lessons_context()
655
+ if lessons:
656
+ spec = f"{lessons}\n\n{spec}"
657
+ except Exception as e:
658
+ logger.warning(f"Lesson injection failed (non-fatal): {e}")
659
+ report.degraded_subsystems.append(f"Lesson injection: {e}")
660
+
661
+ # AB-MCTS branch-and-evaluate is off by default: it fires several serial
662
+ # LLM calls before any work begins (observed ~30 min on one build) for
663
+ # marginal spec refinement. Opt in with orchestrator.enable_ab_mcts.
664
+ if get_setting(project.config, "orchestrator", "enable_ab_mcts"):
665
+ try:
666
+ planner = ABMCTSPlanner(project.llm_client)
667
+ spec = planner.branch_and_evaluate(spec, assessment.summary())
668
+ except Exception as e:
669
+ logger.warning(f"AB-MCTS planning failed (non-fatal): {e}")
670
+ report.degraded_subsystems.append(f"AB-MCTS planning: {e}")
671
+
672
+ # Phase 3: Decompose
673
+ max_tasks = get_setting(project.config, "build", "max_tasks")
674
+ # A --max-tasks flag is a per-run ceiling: the tighter of it and the
675
+ # config cap wins, so a focused/bounded run can't balloon into dozens of
676
+ # tasks (and exhaust the budget) the way a broad spec otherwise would.
677
+ if isinstance(flags.max_tasks, int) and flags.max_tasks > 0:
678
+ max_tasks = min(max_tasks, flags.max_tasks)
679
+ # The decomposer otherwise guesses file paths from feature/test names and
680
+ # the executor then CREATES a wrong new file. Feed it the real file+symbol
681
+ # map so a task targets the actual file that defines the code to change.
682
+ file_map = self._project_file_map(project)
683
+ targets = self._resolve_targets(project)
684
+ tasks = decompose_spec(
685
+ spec,
686
+ assessment,
687
+ mode,
688
+ project.llm_client,
689
+ str(project.path),
690
+ max_tasks=max_tasks,
691
+ file_map=file_map,
692
+ targets=targets,
693
+ )
694
+ tasks = topological_sort(tasks)
695
+
696
+ if flags.dry_run:
697
+ return format_plan(tasks, mode)
698
+
699
+ if confirm_plan:
700
+ console.print(Markdown(format_plan(tasks, mode)))
701
+ if not self._confirm(f"Proceed with these {len(tasks)} tasks?"):
702
+ return "Cancelled: plan not approved."
703
+
704
+ # Phases 4-5: Execute + Gate, wrapped in an outer convergence loop.
705
+ # The loop keeps re-attempting concrete gate failures until the gate is
706
+ # green, the budget runs out, or an iteration makes no progress. The
707
+ # default "auto" is budget-driven: it runs up to CONVERGENCE_CEILING but
708
+ # in practice stops on the budget/no-progress guards below. An explicit
709
+ # positive int caps the iterations hard instead.
710
+ raw_iterations = get_setting(
711
+ project.config, "orchestrator", "max_build_iterations"
712
+ )
713
+ if (
714
+ isinstance(raw_iterations, int)
715
+ and not isinstance(raw_iterations, bool)
716
+ and raw_iterations > 0
717
+ ):
718
+ max_build_iterations = raw_iterations
719
+ else:
720
+ max_build_iterations = CONVERGENCE_CEILING
721
+ iteration = 0
722
+ prev_issues: Optional[list[str]] = None
723
+ while True:
724
+ iteration += 1
725
+ tasks_this_iter = len(tasks)
726
+
727
+ # Phase 4: Execution
728
+ self._execute_tasks(tasks, project, flags, report)
729
+
730
+ # Phase 5: Gates
731
+ if flags.no_verify:
732
+ # No gate to converge on; preserve single-pass behavior.
733
+ break
734
+ gatekeeper = GateKeeper(
735
+ project.path,
736
+ env_activate=env_activate,
737
+ build_timeout=get_setting(project.config, "build", "build_timeout"),
738
+ test_timeout=get_setting(project.config, "build", "test_timeout"),
739
+ lint_timeout=get_setting(project.config, "build", "lint_timeout"),
740
+ lsp_diagnostics=get_setting(
741
+ project.config, "orchestrator", "lsp_diagnostics"
742
+ ),
743
+ lsp_language=project.config.get("language"),
744
+ lsp_timeout=get_setting(project.config, "orchestrator", "lsp_timeout"),
745
+ container=self._container_engine(project),
746
+ mutation_gate=get_setting(
747
+ project.config, "orchestrator", "mutation_gate"
748
+ ),
749
+ mutation_config=project.config.get("mutation") or {},
750
+ runtime_smoke=get_setting(
751
+ project.config, "orchestrator", "runtime_smoke"
752
+ ),
753
+ runtime_config=project.config.get("runtime") or {},
754
+ web_verify=get_setting(project.config, "orchestrator", "web_verify"),
755
+ vision_verify=get_setting(
756
+ project.config, "orchestrator", "vision_verify"
757
+ ),
758
+ vision_client=project.llm_client,
759
+ )
760
+ commands = {
761
+ "build_command": assessment.structure.build_command,
762
+ "test_command": assessment.structure.test_command,
763
+ "lint_command": assessment.structure.lint_command,
764
+ "golden_command": get_setting(
765
+ project.config, "orchestrator", "golden_command"
766
+ ),
767
+ }
768
+ success, issues, final_health = gatekeeper.run_gates(commands)
769
+ validation = ValidationResult()
770
+ validation.build_ok = final_health.builds
771
+ validation.tests_ok = final_health.tests_pass
772
+ validation.lint_ok = final_health.lint_clean
773
+ validation.build_ran = bool(commands["build_command"])
774
+ validation.tests_ran = bool(commands["test_command"])
775
+ validation.lint_ran = bool(commands["lint_command"])
776
+ # Per-target validation: verify EVERY declared sub-project with its
777
+ # own toolchain. A target failure fails the run even if the top-level
778
+ # gate was green (no-op when no targets are declared).
779
+ target_results = self._validate_targets(project, env_activate)
780
+ if target_results:
781
+ summary = ", ".join(
782
+ f"{r['name']}={'OK' if r['ok'] else 'FAIL'}" for r in target_results
783
+ )
784
+ report.key_decisions.append(f"Per-target validation: {summary}")
785
+ failed_targets = [r["name"] for r in target_results if not r["ok"]]
786
+ if failed_targets:
787
+ issues = list(issues) + [
788
+ f"Target validation failed: {', '.join(failed_targets)}"
789
+ ]
790
+ validation.issues = issues
791
+ success = False
792
+ report.validation = validation
793
+ report.validation_passed = success
794
+ report.health_after = final_health
795
+ self.last_build_succeeded = success
796
+ if success:
797
+ break
798
+ logger.warning(f"Validation failed: {issues}")
799
+ self._maybe_rollback_regression(project, report, assessment, flags)
800
+
801
+ # Decide whether to attempt another convergence iteration.
802
+ if iteration >= max_build_iterations:
803
+ break
804
+ if _budget_exhausted(project.llm_client):
805
+ logger.warning("Convergence halted: LLM budget exhausted.")
806
+ report.key_decisions.append(
807
+ "Convergence halted: budget exhausted before next iteration"
808
+ )
809
+ break
810
+ # No-progress guards: don't loop on an iteration that ran nothing or
811
+ # produced the identical failure signature.
812
+ if tasks_this_iter == 0:
813
+ break
814
+ if prev_issues is not None and prev_issues == issues:
815
+ report.key_decisions.append(
816
+ f"Convergence halted: iteration {iteration} reproduced the "
817
+ "identical gate failures (no progress)"
818
+ )
819
+ break
820
+ prev_issues = list(issues)
821
+
822
+ # Build a targeted fix spec from the concrete gate failures plus any
823
+ # failed/deferred tasks, re-decompose it, and re-run the same path.
824
+ fix_spec = self._build_fix_spec(report, issues, final_health)
825
+ fix_tasks = decompose_spec(
826
+ fix_spec,
827
+ assessment,
828
+ mode,
829
+ project.llm_client,
830
+ str(project.path),
831
+ max_tasks=max_tasks,
832
+ file_map=file_map,
833
+ targets=targets,
834
+ )
835
+ tasks = topological_sort(fix_tasks)
836
+ report.key_decisions.append(
837
+ f"Convergence iteration {iteration + 1}: re-attempting "
838
+ f"{len(issues)} gate failure(s) with {len(tasks)} fix task(s)"
839
+ )
840
+ if not tasks:
841
+ report.key_decisions.append(
842
+ f"Convergence halted: iteration {iteration + 1} produced no "
843
+ "fix tasks"
844
+ )
845
+ break
846
+
847
+ # Optional goal-completion check (off by default). Runs AFTER the gate
848
+ # loop has settled: an LLM judge reads the goal, acceptance criteria, and
849
+ # the cumulative diff and reports whether the goal is actually met (gates
850
+ # green != goal met). Advisory by default — gaps are recorded and logged
851
+ # but do not fail the build; it blocks only when block_on_goal_gap is set.
852
+ # Timeout-bounded and best-effort, so it can never hang or crash a build.
853
+ if get_setting(project.config, "orchestrator", "goal_check"):
854
+ self._run_goal_check(project, prompt, tasks, goal_check_base, report)
855
+
856
+ # Phase 6: Metacognitive Audit (best-effort; never fail a finished build)
857
+ report.finalize()
858
+ try:
859
+ auditor.audit_session(
860
+ report.completed_tasks, report.failed_tasks, str(report.scratchpad)
861
+ )
862
+ except Exception as e:
863
+ logger.warning(f"Session audit failed (non-fatal): {e}")
864
+ report.degraded_subsystems.append(f"Session audit: {e}")
865
+
866
+ usage = project.llm_client.cumulative_usage
867
+ report.llm_calls = usage.call_count
868
+ report.llm_tokens = usage.total_tokens
869
+ report.llm_prompt_tokens = getattr(usage, "prompt_tokens", 0)
870
+ report.llm_completion_tokens = getattr(usage, "completion_tokens", 0)
871
+ report.llm_cache_read_tokens = getattr(usage, "cache_read_tokens", 0)
872
+ report.llm_cost = usage.estimated_cost
873
+ report.cost_by_task = dict(getattr(project.llm_client, "cost_by_task", {}))
874
+
875
+ report.save(project.path)
876
+ return report.to_markdown()
877
+
878
+ def _capture_head(self, project: Project) -> Optional[str]:
879
+ """Best-effort current HEAD sha, or None outside a git repo / on error."""
880
+ if not (project.path / ".git").exists():
881
+ return None
882
+ proc = run_git("git rev-parse HEAD", project.path)
883
+ if proc is None:
884
+ return None
885
+ sha = proc.stdout.strip()
886
+ return sha if proc.returncode == 0 and sha else None
887
+
888
+ def _cumulative_diff(self, project: Project, base: Optional[str]) -> str:
889
+ """Diff of the whole build's work for the goal-check judge.
890
+
891
+ When a pre-build base sha is known, diff ``base`` against the working
892
+ tree (committed task commits + uncommitted changes). Otherwise fall back
893
+ to the working-tree diff vs HEAD. Best-effort: returns "" on any error or
894
+ outside a git repo, which the judge treats as no diff.
895
+ """
896
+ if not (project.path / ".git").exists():
897
+ return ""
898
+ cmd = f"git diff {base}" if base else "git diff HEAD"
899
+ proc = run_git(cmd, project.path)
900
+ return proc.stdout if proc and proc.returncode == 0 else ""
901
+
902
+ def _run_goal_check(
903
+ self,
904
+ project: Project,
905
+ prompt: str,
906
+ tasks: list,
907
+ base: Optional[str],
908
+ report: BuildReport,
909
+ ) -> None:
910
+ """Run the optional goal-completion check and record its verdict.
911
+
912
+ Advisory by default: a GAP verdict records gaps into the report and logs
913
+ them but does NOT fail the build. It blocks (marks validation failed and
914
+ appends a blocking issue) only when ``orchestrator.block_on_goal_gap`` is
915
+ true. SKIP (no goal/criteria/client, unparseable, timeout, error) is a
916
+ no-op. Wrapped so a judge failure can never crash a finished build.
917
+ """
918
+ from misterdev.core.verification.goal_check import (
919
+ GAP,
920
+ build_evidence,
921
+ run_goal_check,
922
+ )
923
+
924
+ try:
925
+ criteria = "\n".join(
926
+ f"- {t.acceptance_criteria}"
927
+ for t in tasks
928
+ if getattr(t, "acceptance_criteria", "")
929
+ )
930
+ diff = self._cumulative_diff(project, base)
931
+ summary = "; ".join(
932
+ t.title or t.description[:60] for t in report.completed_tasks
933
+ )
934
+ evidence = build_evidence(diff=diff, summary=summary)
935
+ timeout = get_setting(project.config, "orchestrator", "goal_check_timeout")
936
+ judge_model = (project.config.get("judge") or {}).get("model")
937
+ verdict = run_goal_check(
938
+ prompt,
939
+ criteria,
940
+ evidence,
941
+ llm_client=project.llm_client,
942
+ judge_model=judge_model,
943
+ timeout=timeout,
944
+ )
945
+ except Exception as e:
946
+ logger.warning(f"Goal-completion check failed (non-fatal): {e}")
947
+ report.degraded_subsystems.append(f"Goal-completion check: {e}")
948
+ return
949
+
950
+ if verdict.status != GAP:
951
+ logger.info(f"Goal-completion check: {verdict.status} ({verdict.reason})")
952
+ return
953
+
954
+ report.goal_gaps = list(verdict.gaps)
955
+ logger.warning(f"Goal-completion check found {len(verdict.gaps)} gap(s):")
956
+ for gap in verdict.gaps:
957
+ logger.warning(f" goal gap: {gap}")
958
+
959
+ if get_setting(project.config, "orchestrator", "block_on_goal_gap"):
960
+ report.validation_passed = False
961
+ self.last_build_succeeded = False
962
+ issue = "Goal-completion check: " + "; ".join(verdict.gaps)
963
+ if report.validation is not None:
964
+ report.validation.issues.append(issue)
965
+ else:
966
+ report.key_decisions.append(issue)
967
+
968
+ def _build_fix_spec(
969
+ self,
970
+ report: BuildReport,
971
+ issues: list[str],
972
+ final_health: HealthCheck,
973
+ ) -> str:
974
+ """Compose a targeted spec from the gate's concrete failures.
975
+
976
+ Used by the convergence loop for iterations 2+: instead of re-running
977
+ expensive discovery, it points decomposition straight at what the gate
978
+ flagged (build/test/lint output, failed and deferred tasks) so the next
979
+ pass fixes the gap rather than re-planning the whole build.
980
+ """
981
+ parts = ["# Convergence Fix Spec", "## Goal: make the gate pass\n"]
982
+ if issues:
983
+ parts.append("### Gate Failures")
984
+ for item in issues:
985
+ parts.append(f"- {item}")
986
+ if not final_health.builds and final_health.build_output:
987
+ parts.append(f"\n### Build Output\n{final_health.build_output[:1000]}")
988
+ if not final_health.tests_pass and final_health.test_output:
989
+ parts.append(f"\n### Test Output\n{final_health.test_output[:1000]}")
990
+ if not final_health.lint_clean and final_health.lint_output:
991
+ parts.append(f"\n### Lint Output\n{final_health.lint_output[:1000]}")
992
+ if report.failed_tasks:
993
+ parts.append("\n### Failed Tasks")
994
+ for t in report.failed_tasks:
995
+ parts.append(f"- {t.id}: {t.title}")
996
+ if report.deferred_tasks:
997
+ parts.append("\n### Deferred Tasks")
998
+ for t in report.deferred_tasks:
999
+ parts.append(f"- {t.id}: {t.title}")
1000
+ return "\n".join(parts)
1001
+
1002
+ @staticmethod
1003
+ def _wave_commits(executor, project, tasks) -> list:
1004
+ """Collect ``(task_id, sha)`` for each task that has a recorded commit,
1005
+ skipping tasks with none. Shared by the regression-revert and
1006
+ integration-gate paths."""
1007
+ commits = []
1008
+ for t in tasks:
1009
+ sha = executor.find_task_commit(project, t.id)
1010
+ if sha:
1011
+ commits.append((t.id, sha))
1012
+ return commits
1013
+
1014
+ def _maybe_rollback_regression(
1015
+ self,
1016
+ project: Project,
1017
+ report: BuildReport,
1018
+ assessment: ProjectAssessment,
1019
+ flags: BuildFlags,
1020
+ ) -> None:
1021
+ """If the post-build gate failed, bisect task commits and revert the culprit."""
1022
+ if flags.no_rollback or not report.completed_tasks:
1023
+ return
1024
+ test_cmd = assessment.structure.test_command
1025
+ if not test_cmd:
1026
+ return
1027
+ ex = MarkdownPlanExecutor()
1028
+ if not ex._is_git_repo(project):
1029
+ return
1030
+ commits = self._wave_commits(ex, project, report.completed_tasks)
1031
+ if not commits:
1032
+ return
1033
+ logger.warning("Post-build regression detected; bisecting task commits...")
1034
+ culprit = ex.bisect_regression(project, commits, test_cmd)
1035
+ if not culprit:
1036
+ logger.info("Bisect did not isolate a single task commit.")
1037
+ return
1038
+ sha = dict(commits)[culprit]
1039
+ if ex.revert_task_commit(project, sha):
1040
+ logger.warning(
1041
+ f"Regression bisected to {culprit}; commit {sha[:8]} reverted."
1042
+ )
1043
+ report.key_decisions.append(
1044
+ f"Regression from {culprit} auto-reverted (bisect)"
1045
+ )
1046
+
1047
+ def _suite_failures(
1048
+ self,
1049
+ project: Project,
1050
+ executor: MarkdownPlanExecutor,
1051
+ test_cmd: str,
1052
+ timeout: int,
1053
+ cwd=None,
1054
+ ) -> Optional[int]:
1055
+ """Full-suite failure count: 0 when green, the parsed count when red, or
1056
+ None when the count can't be parsed (caller then can't count-compare).
1057
+
1058
+ ``cwd`` runs the command in a sub-project (target) directory; defaults to
1059
+ the repo root."""
1060
+ from misterdev.core.verification.validator import (
1061
+ _parse_test_counts,
1062
+ )
1063
+
1064
+ ok, output = executor._run_command(project, test_cmd, timeout=timeout, cwd=cwd)
1065
+ if ok:
1066
+ return 0
1067
+ total, failures = _parse_test_counts(output)
1068
+ return failures if total > 0 else None
1069
+
1070
+ def _integration_gate_count(
1071
+ self,
1072
+ project: Project,
1073
+ executor: MarkdownPlanExecutor,
1074
+ test_cmd: str,
1075
+ wave_tasks: list[Task],
1076
+ timeout: int,
1077
+ baseline_failures: int,
1078
+ ) -> list[str]:
1079
+ """Count-mode gate for a RED baseline: revert wave commits (newest first)
1080
+ only when the wave RAISED the full-suite failure count above the baseline.
1081
+
1082
+ This closes the gap where, with the binary gate disabled by a red
1083
+ baseline, a task gated on its own scoped tests could worsen the overall
1084
+ suite and still commit. An unparseable post-wave count is left alone (we
1085
+ do not revert on a number we can't read).
1086
+ """
1087
+ after = self._suite_failures(project, executor, test_cmd, timeout)
1088
+ if after is None or after <= baseline_failures:
1089
+ return []
1090
+ logger.warning(
1091
+ f"Integration gate (count): failures rose {baseline_failures} -> "
1092
+ f"{after}; reverting wave commits until restored."
1093
+ )
1094
+ commits = self._wave_commits(executor, project, wave_tasks)
1095
+ reverted: list[str] = []
1096
+ for tid, sha in reversed(commits):
1097
+ if executor.revert_task_commit(project, sha):
1098
+ reverted.append(tid)
1099
+ now = self._suite_failures(project, executor, test_cmd, timeout)
1100
+ if now is not None and now <= baseline_failures:
1101
+ break
1102
+ return reverted
1103
+
1104
+ @staticmethod
1105
+ def _target_regressed(after: Optional[int], baseline: Optional[int]) -> bool:
1106
+ """Did a target's gate regress vs its baseline?
1107
+
1108
+ ``after``/``baseline`` are :meth:`_suite_failures` results (0 green, N
1109
+ count, None unparseable). A green-now gate never regressed. With no
1110
+ countable baseline we can't compare, so we don't revert. A binary failure
1111
+ now (None) is a regression only if the target was green (baseline 0);
1112
+ otherwise compare counts.
1113
+ """
1114
+ if after == 0:
1115
+ return False
1116
+ if baseline is None:
1117
+ return False
1118
+ if after is None:
1119
+ return baseline == 0
1120
+ return after > baseline
1121
+
1122
+ def _integration_gate_targets(
1123
+ self,
1124
+ project: Project,
1125
+ executor: MarkdownPlanExecutor,
1126
+ targets: list[dict],
1127
+ wave_tasks: list[Task],
1128
+ timeout: int,
1129
+ target_baselines: dict,
1130
+ ) -> list[str]:
1131
+ """Per-target integration gate: validate each sub-project the wave touched
1132
+ with ITS own toolchain (in ITS directory), reverting only the wave commits
1133
+ belonging to a target that regressed. This is the multi-target analogue of
1134
+ :meth:`_integration_gate` — the last place a polyglot run would otherwise
1135
+ gate with the wrong toolchain.
1136
+ """
1137
+ from misterdev.core.planning.targets import select_target
1138
+
1139
+ reverted: list[str] = []
1140
+ for tgt in targets:
1141
+ gate_cmd = tgt.get("test_command") or tgt.get("build_command")
1142
+ if not gate_cmd:
1143
+ continue
1144
+ tname = tgt.get("name") or tgt.get("path")
1145
+ tp = (tgt.get("path") or "").strip("/")
1146
+ run_dir = project.path / tp if tp else project.path
1147
+ owned = [
1148
+ t
1149
+ for t in wave_tasks
1150
+ if (
1151
+ select_target(
1152
+ targets, list(t.files_to_modify) + list(t.files_to_create)
1153
+ )
1154
+ or {}
1155
+ ).get("path")
1156
+ == tgt.get("path")
1157
+ ]
1158
+ if not owned:
1159
+ continue
1160
+ baseline = target_baselines.get(tname)
1161
+ after = self._suite_failures(
1162
+ project, executor, gate_cmd, timeout, cwd=run_dir
1163
+ )
1164
+ if not self._target_regressed(after, baseline):
1165
+ continue
1166
+ logger.warning(
1167
+ f"Integration gate [{tname}]: regressed (baseline={baseline}, "
1168
+ f"after={after}); reverting this target's wave commits."
1169
+ )
1170
+ commits = [(t.id, executor.find_task_commit(project, t.id)) for t in owned]
1171
+ commits = [(tid, sha) for tid, sha in commits if sha]
1172
+ for tid, sha in reversed(commits):
1173
+ if executor.revert_task_commit(project, sha):
1174
+ reverted.append(tid)
1175
+ now = self._suite_failures(
1176
+ project, executor, gate_cmd, timeout, cwd=run_dir
1177
+ )
1178
+ if not self._target_regressed(now, baseline):
1179
+ break
1180
+ return reverted
1181
+
1182
+ def _integration_gate(
1183
+ self,
1184
+ project: Project,
1185
+ executor: MarkdownPlanExecutor,
1186
+ test_cmd: str,
1187
+ wave_tasks: list[Task],
1188
+ timeout: int,
1189
+ baseline_failures: int = 0,
1190
+ ) -> list[str]:
1191
+ """Run the full suite after a wave; revert task commits that regressed it.
1192
+
1193
+ Returns the task_ids whose commits were reverted (empty if the suite
1194
+ still passes). Bisects to the single culprit when possible; if that
1195
+ can't isolate it or the tree is still red afterward, reverts the
1196
+ remaining wave commits (newest first) to restore a green baseline. When
1197
+ ``baseline_failures`` > 0 (a red baseline that could still be counted),
1198
+ runs in count mode instead — reverting only a wave that raises the count.
1199
+ """
1200
+ if baseline_failures > 0:
1201
+ return self._integration_gate_count(
1202
+ project, executor, test_cmd, wave_tasks, timeout, baseline_failures
1203
+ )
1204
+ ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
1205
+ if ok:
1206
+ return []
1207
+
1208
+ commits = self._wave_commits(executor, project, wave_tasks)
1209
+ if not commits:
1210
+ logger.warning(
1211
+ "Integration gate: suite regressed but no task commits found to revert."
1212
+ )
1213
+ return []
1214
+
1215
+ logger.warning("Integration gate: suite regressed; isolating culprit...")
1216
+ reverted: list[str] = []
1217
+ culprit = executor.bisect_regression(
1218
+ project, commits, test_cmd, timeout=timeout
1219
+ )
1220
+ if culprit:
1221
+ sha = dict(commits)[culprit]
1222
+ if executor.revert_task_commit(project, sha):
1223
+ reverted.append(culprit)
1224
+ ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
1225
+ if ok:
1226
+ return reverted
1227
+
1228
+ for tid, sha in reversed(commits):
1229
+ if tid in reverted:
1230
+ continue
1231
+ if executor.revert_task_commit(project, sha):
1232
+ reverted.append(tid)
1233
+ ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
1234
+ if ok:
1235
+ break
1236
+ return reverted
1237
+
1238
+ def _execute_tasks(
1239
+ self,
1240
+ tasks: list[Task],
1241
+ project: Project,
1242
+ flags: BuildFlags,
1243
+ report: BuildReport,
1244
+ ) -> None:
1245
+ scratchpad = Scratchpad()
1246
+ aligner = RealTimeAligner(project.path)
1247
+ contracts = ContractRegistry(project.path)
1248
+ progress = ProgressTracker(project.path)
1249
+ changes = ChangeTracker(project.path)
1250
+ strategy_optimizer = StrategyOptimizer()
1251
+ executor = MarkdownPlanExecutor(scratchpad=scratchpad)
1252
+ report.scratchpad = scratchpad
1253
+
1254
+ max_consecutive_failures = get_setting(
1255
+ project.config, "orchestrator", "max_consecutive_failures"
1256
+ )
1257
+
1258
+ completed_ids = set(progress.completed)
1259
+ failed_ids: set[str] = set()
1260
+ consecutive_failures = 0
1261
+ aborted = False
1262
+ max_cost_per_task = get_setting(
1263
+ project.config, "orchestrator", "max_cost_per_task"
1264
+ )
1265
+
1266
+ # Register decomposed tasks with the TaskManager so status updates,
1267
+ # progress tracking, and contract lookups resolve by ID (otherwise
1268
+ # build()'s LLM-decomposed task IDs are unknown to the manager).
1269
+ for task in tasks:
1270
+ project.task_manager.tasks.setdefault(task.id, task)
1271
+
1272
+ if completed_ids:
1273
+ logger.info(f"Resuming build: {len(completed_ids)} tasks already completed")
1274
+
1275
+ lang = (project.config.get("language") or "python").lower()
1276
+ remaining = list(tasks)
1277
+
1278
+ # Integration gate: after each wave, re-run the full suite and revert
1279
+ # any task whose merge regressed it. Per-task validation passes a task
1280
+ # in isolation but can't see cross-module breakage that only surfaces
1281
+ # when the whole package is imported together; without this, broken
1282
+ # tasks accumulate under later merges until the end-of-build gate.
1283
+ # Fold the golden suite into the per-wave gate so a task that breaks the
1284
+ # immutable contract is bisected and reverted immediately, not just at
1285
+ # the end-of-iteration GateKeeper.
1286
+ test_cmd = _combine_commands(
1287
+ report.assessment.structure.test_command,
1288
+ get_setting(project.config, "orchestrator", "golden_command"),
1289
+ )
1290
+ test_timeout = get_setting(project.config, "build", "test_timeout")
1291
+ gate_targets = project.config.get("targets") or []
1292
+ gate_active = (
1293
+ not flags.no_rollback
1294
+ and get_setting(project.config, "orchestrator", "integration_gate")
1295
+ and (bool(test_cmd) or bool(gate_targets))
1296
+ and (Path(project.path) / ".git").exists()
1297
+ )
1298
+ # Per-target baselines (multi-target): measure each sub-project's gate in
1299
+ # its own dir ONCE up front, so the per-wave gate reverts only a target
1300
+ # that REGRESSED, and the executor's per-task gate uses the right baseline.
1301
+ target_baselines: dict = {}
1302
+ if gate_active and gate_targets:
1303
+ for tgt in gate_targets:
1304
+ gcmd = tgt.get("test_command") or tgt.get("build_command")
1305
+ if not gcmd:
1306
+ continue
1307
+ tname = tgt.get("name") or tgt.get("path")
1308
+ tp = (tgt.get("path") or "").strip("/")
1309
+ rdir = project.path / tp if tp else project.path
1310
+ target_baselines[tname] = self._suite_failures(
1311
+ project, executor, gcmd, test_timeout, cwd=rdir
1312
+ )
1313
+ project.target_baselines = {
1314
+ k: int(v or 0) for k, v in target_baselines.items()
1315
+ }
1316
+ baseline_failures = 0
1317
+ if gate_active and test_cmd:
1318
+ baseline_ok, baseline_out = executor._run_command(
1319
+ project, test_cmd, timeout=test_timeout
1320
+ )
1321
+ if not baseline_ok:
1322
+ # A red baseline used to fully disable the gate, so a task could
1323
+ # WORSEN the suite (more failures than at the start) and still
1324
+ # commit. Instead, run the gate in COUNT mode against the baseline
1325
+ # failure count when it is parseable — reverting only a wave that
1326
+ # increases failures. Unparseable count -> disable as before.
1327
+ from misterdev.core.verification.validator import (
1328
+ _parse_test_counts,
1329
+ )
1330
+
1331
+ total, fails = _parse_test_counts(baseline_out)
1332
+ if total > 0 and fails > 0:
1333
+ baseline_failures = fails
1334
+ logger.info(
1335
+ f"Integration gate in COUNT mode: baseline has {fails} "
1336
+ "failing test(s); a wave that raises the count is reverted."
1337
+ )
1338
+ else:
1339
+ logger.info(
1340
+ "Integration gate disabled: baseline failing and test "
1341
+ "count unparseable."
1342
+ )
1343
+ gate_active = False
1344
+
1345
+ while remaining and not aborted:
1346
+ # Graceful budget stop: if the global budget is exhausted, do not
1347
+ # launch another wave. Defer the remainder and break so the pipeline
1348
+ # finalizes/reports normally instead of throwing mid-wave.
1349
+ if _budget_exhausted(project.llm_client):
1350
+ logger.warning("Stopping run: LLM budget exhausted.")
1351
+ report.key_decisions.append(
1352
+ "Stopped: budget exhausted; remaining work deferred"
1353
+ )
1354
+ for task in remaining:
1355
+ report.deferred_tasks.append(task)
1356
+ break
1357
+
1358
+ # Find all tasks whose dependencies are satisfied
1359
+ ready = []
1360
+ still_waiting = []
1361
+ for task in remaining:
1362
+ # Skip only if this exact task (id AND content hash) already
1363
+ # completed. Hash-aware so a freshly decomposed plan that reuses
1364
+ # generic ids (T-001...) from a prior build is not wrongly
1365
+ # skipped against stale progress state.
1366
+ if not progress.needs_rerun(
1367
+ task.id, compute_task_hash(task, project.path)
1368
+ ):
1369
+ report.completed_tasks.append(task)
1370
+ completed_ids.add(task.id)
1371
+ continue
1372
+ if any(d in failed_ids for d in task.dependencies):
1373
+ report.deferred_tasks.append(task)
1374
+ failed_ids.add(task.id)
1375
+ continue
1376
+ if any(d not in completed_ids for d in task.dependencies):
1377
+ still_waiting.append(task)
1378
+ continue
1379
+ ready.append(task)
1380
+
1381
+ if not ready:
1382
+ for task in still_waiting:
1383
+ report.deferred_tasks.append(task)
1384
+ break
1385
+
1386
+ # Prepare tasks with context
1387
+ for task in ready:
1388
+ strategy = strategy_optimizer.select_best_strategy(
1389
+ task.description,
1390
+ task.category,
1391
+ report.assessment.summary(),
1392
+ project.llm_client,
1393
+ )
1394
+ task.processor_data["strategy"] = strategy
1395
+ task.processor_data["consensus_context"] = (
1396
+ aligner.get_consensus_context()
1397
+ )
1398
+ task.processor_data["interface_contracts"] = (
1399
+ contracts.get_contracts_for_task(task.dependencies)
1400
+ )
1401
+ task.processor_data["recent_changes"] = (
1402
+ changes.get_recent_changes_for_files(
1403
+ task.files_to_modify + task.files_to_create
1404
+ )
1405
+ )
1406
+
1407
+ if flags.interactive:
1408
+ filtered_ready = []
1409
+ for task in ready:
1410
+ action = self._interactive_prompt(
1411
+ task, task.processor_data.get("strategy", "iterative")
1412
+ )
1413
+ if action == "quit":
1414
+ aborted = True
1415
+ break
1416
+ elif action == "skip":
1417
+ report.deferred_tasks.append(task)
1418
+ else:
1419
+ filtered_ready.append(task)
1420
+ ready = filtered_ready
1421
+ if aborted:
1422
+ break
1423
+
1424
+ if not ready:
1425
+ remaining = still_waiting
1426
+ continue
1427
+
1428
+ # Execute: parallel or sequential
1429
+ wave_completed: list[Task] = []
1430
+ if flags.parallel and len(ready) > 1:
1431
+ logger.info(
1432
+ f"Executing {len(ready)} tasks in parallel: {[t.id for t in ready]}"
1433
+ )
1434
+ results = self._execute_parallel(ready, executor, project)
1435
+ else:
1436
+ results = []
1437
+ for task in ready:
1438
+ try:
1439
+ result = executor.execute(task, project)
1440
+ results.append((task, result, None))
1441
+ except Exception as e:
1442
+ results.append((task, None, e))
1443
+
1444
+ # Process results
1445
+ for task, result, error in results:
1446
+ if isinstance(error, BudgetExceededError):
1447
+ # Budget ran out mid-task: revert any partial work and stop
1448
+ # the run gracefully rather than recording a failure.
1449
+ logger.warning(
1450
+ f"Stopping run: budget exhausted during {task.id} ({error})."
1451
+ )
1452
+ sha = executor.find_task_commit(project, task.id)
1453
+ if sha:
1454
+ executor.revert_task_commit(project, sha)
1455
+ report.deferred_tasks.append(task)
1456
+ report.key_decisions.append(
1457
+ "Stopped: budget exhausted; remaining work deferred"
1458
+ )
1459
+ aborted = True
1460
+ break
1461
+ exceeded_fn = getattr(project.llm_client, "task_cost_exceeded", None)
1462
+ if (
1463
+ max_cost_per_task is not None
1464
+ and exceeded_fn
1465
+ and exceeded_fn(task.id)
1466
+ ):
1467
+ # This task alone blew its per-task cost cap: abandon and
1468
+ # revert it cleanly instead of burning more budget retrying.
1469
+ cap_fn = getattr(project.llm_client, "effective_task_cap", None)
1470
+ cap = (cap_fn(task.id) if cap_fn else None) or 0.0
1471
+ logger.warning(
1472
+ f"Task {task.id} hit per-task cost cap "
1473
+ f"(${cap:.2f}); abandoning and reverting."
1474
+ )
1475
+ sha = executor.find_task_commit(project, task.id)
1476
+ if sha:
1477
+ executor.revert_task_commit(project, sha)
1478
+ failed_ids.add(task.id)
1479
+ progress.mark_failed(task.id)
1480
+ report.deferred_tasks.append(task)
1481
+ report.key_decisions.append(
1482
+ f"Task {task.id} deferred: exceeded per-task cost cap "
1483
+ f"(${cap:.2f})"
1484
+ )
1485
+ continue
1486
+ if error:
1487
+ logger.error(f"Task {task.id} raised: {error}")
1488
+ failed_ids.add(task.id)
1489
+ progress.mark_failed(task.id)
1490
+ report.failed_tasks.append(task)
1491
+ consecutive_failures += 1
1492
+ elif result.status == "completed":
1493
+ task.execution_history.append(result)
1494
+ completed_ids.add(task.id)
1495
+ progress.mark_completed(
1496
+ task.id, compute_task_hash(task, project.path)
1497
+ )
1498
+ report.completed_tasks.append(task)
1499
+ wave_completed.append(task)
1500
+ consecutive_failures = 0
1501
+ modified = task.files_to_modify + task.files_to_create
1502
+ if modified:
1503
+ contracts.extract_contracts(
1504
+ task.id,
1505
+ modified,
1506
+ project.path,
1507
+ project.llm_client,
1508
+ language=lang,
1509
+ )
1510
+ changes.record_task_changes(task.id, modified)
1511
+ if task.complexity == "architectural":
1512
+ aligner.certify_decision(task.title, task.description)
1513
+ else:
1514
+ task.execution_history.append(result)
1515
+ failed_ids.add(task.id)
1516
+ progress.mark_failed(task.id)
1517
+ report.failed_tasks.append(task)
1518
+ consecutive_failures += 1
1519
+
1520
+ if consecutive_failures >= max_consecutive_failures:
1521
+ aborted = True
1522
+ break
1523
+
1524
+ # Integration gate: after this wave's merges, revert any task that
1525
+ # regressed the full suite before the next wave builds on top of it.
1526
+ if gate_active and wave_completed:
1527
+ if gate_targets:
1528
+ # Polyglot: gate each touched sub-project with its own
1529
+ # toolchain in its own directory (replaces the top-level gate,
1530
+ # which would use the wrong commands for a frontend wave).
1531
+ reverted = self._integration_gate_targets(
1532
+ project,
1533
+ executor,
1534
+ gate_targets,
1535
+ wave_completed,
1536
+ test_timeout,
1537
+ target_baselines,
1538
+ )
1539
+ else:
1540
+ reverted = self._integration_gate(
1541
+ project,
1542
+ executor,
1543
+ test_cmd,
1544
+ wave_completed,
1545
+ test_timeout,
1546
+ baseline_failures=baseline_failures,
1547
+ )
1548
+ for tid in reverted:
1549
+ completed_ids.discard(tid)
1550
+ failed_ids.add(tid)
1551
+ progress.mark_failed(tid)
1552
+ obj = next((t for t in wave_completed if t.id == tid), None)
1553
+ if obj is not None and obj in report.completed_tasks:
1554
+ report.completed_tasks.remove(obj)
1555
+ report.failed_tasks.append(obj)
1556
+ report.key_decisions.append(
1557
+ f"Integration gate: {tid} reverted (regressed full suite)"
1558
+ )
1559
+ consecutive_failures += 1
1560
+ if reverted and consecutive_failures >= max_consecutive_failures:
1561
+ aborted = True
1562
+
1563
+ remaining = still_waiting
1564
+
1565
+ # Defer any unprocessed tasks
1566
+ processed_ids = (
1567
+ completed_ids | failed_ids | {t.id for t in report.deferred_tasks}
1568
+ )
1569
+ for task in tasks:
1570
+ if task.id not in processed_ids:
1571
+ report.deferred_tasks.append(task)
1572
+
1573
+ @staticmethod
1574
+ def _task_file_set(task: Task) -> set:
1575
+ """Declared files a task will touch (modify + create).
1576
+
1577
+ Tolerates non-list values (e.g. unconfigured mocks): only real lists
1578
+ contribute paths, anything else is treated as "unknown / no claim".
1579
+ """
1580
+ files: set = set()
1581
+ for attr in ("files_to_modify", "files_to_create"):
1582
+ value = getattr(task, attr, None)
1583
+ if isinstance(value, list):
1584
+ files.update(str(p) for p in value)
1585
+ return files
1586
+
1587
+ @classmethod
1588
+ def _partition_disjoint(cls, ready: list[Task]) -> tuple[list, list]:
1589
+ """Split tasks into a concurrent-safe group + a serial remainder.
1590
+
1591
+ A task joins the concurrent group only if its declared file set is
1592
+ disjoint from every task already in that group; otherwise it is
1593
+ deferred to the serial remainder so overlapping writes can't interleave.
1594
+ """
1595
+ concurrent_group: list = []
1596
+ serial_remainder: list = []
1597
+ claimed: set = set()
1598
+ for task in ready:
1599
+ files = cls._task_file_set(task)
1600
+ if files & claimed:
1601
+ serial_remainder.append(task)
1602
+ else:
1603
+ concurrent_group.append(task)
1604
+ claimed |= files
1605
+ return concurrent_group, serial_remainder
1606
+
1607
+ def _execute_parallel(
1608
+ self, ready: list[Task], executor: MarkdownPlanExecutor, project: Project
1609
+ ) -> list:
1610
+ """Execute a batch of independent tasks concurrently.
1611
+
1612
+ In "worktree" mode each task runs in its own git worktree so parallel
1613
+ edits can't collide. When the mode is left at its default and the
1614
+ project is a git repo, worktree isolation is preferred automatically;
1615
+ "shared" must be requested explicitly to opt out. In shared mode only
1616
+ tasks with disjoint declared file sets run in the same concurrent batch;
1617
+ tasks whose file sets overlap are run serially afterwards.
1618
+ """
1619
+ mode = get_setting(project.config, "orchestrator", "parallel_mode")
1620
+ is_git_repo = (project.path / ".git").exists() is True
1621
+ # "auto" (default) isolates via worktrees on a git repo; the value itself
1622
+ # carries the intent, so no fragile "was it explicitly set" detection.
1623
+ prefer_worktrees = mode == "worktree" or (mode == "auto" and is_git_repo)
1624
+ if prefer_worktrees and is_git_repo:
1625
+ return self._execute_parallel_worktrees(ready, executor, project)
1626
+
1627
+ concurrent_group, serial_remainder = self._partition_disjoint(ready)
1628
+ results = []
1629
+ max_workers = get_setting(project.config, "orchestrator", "max_workers")
1630
+ if concurrent_group:
1631
+ with concurrent.futures.ThreadPoolExecutor(
1632
+ max_workers=min(len(concurrent_group), max_workers)
1633
+ ) as pool:
1634
+ future_to_task = {
1635
+ pool.submit(
1636
+ executor.execute, task, project, use_git_branch=False
1637
+ ): task
1638
+ for task in concurrent_group
1639
+ }
1640
+ for future in concurrent.futures.as_completed(future_to_task):
1641
+ task = future_to_task[future]
1642
+ try:
1643
+ result = future.result()
1644
+ results.append((task, result, None))
1645
+ except Exception as e:
1646
+ results.append((task, None, e))
1647
+
1648
+ # Tasks with overlapping file claims run one at a time.
1649
+ for task in serial_remainder:
1650
+ try:
1651
+ result = executor.execute(task, project, use_git_branch=False)
1652
+ results.append((task, result, None))
1653
+ except Exception as e:
1654
+ results.append((task, None, e))
1655
+ return results
1656
+
1657
+ def _execute_parallel_worktrees(
1658
+ self, ready: list[Task], executor: MarkdownPlanExecutor, project: Project
1659
+ ) -> list:
1660
+ """Run each task in an isolated git worktree, then merge successes back.
1661
+
1662
+ Worktrees are created and merged serially (git's index/worktree metadata
1663
+ is not concurrency-safe); only the task bodies run in parallel.
1664
+ """
1665
+ import uuid
1666
+ from misterdev.tools.git_tool import GitTool
1667
+
1668
+ git = GitTool({})
1669
+ wt_root = project.path / ".orchestrator" / "worktrees"
1670
+ wt_root.mkdir(parents=True, exist_ok=True)
1671
+ results: list = []
1672
+ prepared: list = []
1673
+
1674
+ for task in ready:
1675
+ branch = f"task/{task.id}"
1676
+ wt_path = wt_root / f"{task.id}-{uuid.uuid4().hex[:6]}"
1677
+ ok, out = git.worktree_add(project, str(wt_path), branch, new_branch=True)
1678
+ if ok:
1679
+ prepared.append((task, wt_path, branch))
1680
+ else:
1681
+ logger.error(f"Worktree add failed for {task.id}: {out}")
1682
+ results.append(
1683
+ (task, None, RuntimeError(f"worktree add failed: {out}"))
1684
+ )
1685
+
1686
+ def run_one(item):
1687
+ task, wt_path, branch = item
1688
+ view = _WorktreeProjectView(project, wt_path)
1689
+ try:
1690
+ return (
1691
+ task,
1692
+ executor.execute(task, view, use_git_branch=False),
1693
+ None,
1694
+ wt_path,
1695
+ branch,
1696
+ )
1697
+ except Exception as e:
1698
+ return (task, None, e, wt_path, branch)
1699
+
1700
+ max_workers = get_setting(project.config, "orchestrator", "max_workers")
1701
+ raw = []
1702
+ if prepared:
1703
+ with concurrent.futures.ThreadPoolExecutor(
1704
+ max_workers=min(len(prepared), max_workers)
1705
+ ) as pool:
1706
+ futures = [pool.submit(run_one, item) for item in prepared]
1707
+ raw = [f.result() for f in concurrent.futures.as_completed(futures)]
1708
+
1709
+ for task, result, error, wt_path, branch in raw:
1710
+ if result is not None and getattr(result, "status", None) == "completed":
1711
+ merged, mout = git.merge_worktree(project, branch)
1712
+ if not merged:
1713
+ logger.error(f"Worktree merge failed for {task.id}: {mout}")
1714
+ error, result = RuntimeError(f"merge failed: {mout}"), None
1715
+ git.worktree_remove(project, str(wt_path))
1716
+ results.append((task, result, error))
1717
+ return results
1718
+
1719
+ def _interactive_prompt(self, task: Task, strategy: str = "iterative") -> str:
1720
+ console.print(
1721
+ f"\n[bold cyan]Next Task:[/] [{task.id}] {task.title} ([bold magenta]{strategy.upper()}[/])"
1722
+ )
1723
+ choice = Prompt.ask("Proceed?", choices=["y", "n", "s", "q"], default="y")
1724
+ return {"y": "proceed", "q": "quit", "s": "skip", "n": "quit"}[choice]
1725
+
1726
+ def _generate_spec(
1727
+ self,
1728
+ mode: BuildMode,
1729
+ prompt: str,
1730
+ assessment: ProjectAssessment,
1731
+ project: Project,
1732
+ facts: str = "",
1733
+ ) -> str:
1734
+ """Phase 2: Generate a spec based on mode."""
1735
+ if mode == BuildMode.DEBUG:
1736
+ parts = ["# Debug Spec\n## Broken Items"]
1737
+ for item in assessment.features.broken:
1738
+ parts.append(f"- {item}")
1739
+ if assessment.features.stubs:
1740
+ parts.append("\n## Stubs")
1741
+ for item in assessment.features.stubs:
1742
+ parts.append(f"- {item}")
1743
+ if not assessment.health.builds:
1744
+ parts.append(
1745
+ f"\n## Build Failure\n{assessment.health.build_output[:500]}"
1746
+ )
1747
+ if not assessment.health.tests_pass:
1748
+ parts.append(
1749
+ f"\n## Test Failures\n{assessment.health.test_output[:500]}"
1750
+ )
1751
+ return "\n".join(parts)
1752
+
1753
+ if mode == BuildMode.COMPLETE:
1754
+ parts = [
1755
+ f"# Completion Spec\n## Project: {project.name}",
1756
+ "## Goal: Complete all work\n",
1757
+ "### Must Complete",
1758
+ ]
1759
+ for f in assessment.features.incomplete:
1760
+ parts.append(f"- {f.name}: {f.description}")
1761
+ parts.append("\n### Must Fix")
1762
+ for item in assessment.features.broken:
1763
+ parts.append(f"- {item}")
1764
+ for item in assessment.features.stubs:
1765
+ parts.append(f"- Stub: {item}")
1766
+ parts.append("\n### Should Add")
1767
+ for f in assessment.features.missing:
1768
+ parts.append(f"- {f.name}: {f.description}")
1769
+ if assessment.features.todos:
1770
+ parts.append(f"\n### TODOs ({len(assessment.features.todos)} items)")
1771
+ for todo in assessment.features.todos[:20]:
1772
+ parts.append(
1773
+ f"- {todo.get('file', '?')}:{todo.get('line', '?')} {todo.get('text', '')}"
1774
+ )
1775
+ return "\n".join(parts)
1776
+
1777
+ if mode == BuildMode.SPEC:
1778
+ spec_path = project.path / prompt.strip()
1779
+ if spec_path.exists():
1780
+ return spec_path.read_text(encoding="utf-8")
1781
+ return f"Spec file not found: {prompt}"
1782
+
1783
+ if mode in (BuildMode.CREATE, BuildMode.SMART):
1784
+ expand_prompt = (
1785
+ f"Expand the following into a comprehensive project spec.\n"
1786
+ f"Include: features with acceptance criteria, error handling, "
1787
+ f"input validation, testing strategy, architecture decisions.\n\n"
1788
+ f"Project context: {assessment.context.purpose}\n"
1789
+ f"Conventions: {assessment.context.conventions}\n"
1790
+ f"Languages: {assessment.structure.languages}\n"
1791
+ f"Frameworks: {assessment.structure.frameworks}\n"
1792
+ f"Existing features: {[f.name for f in assessment.features.existing]}\n"
1793
+ f"Verified facts: {facts}\n\n"
1794
+ f"Description: {prompt}\n\nReturn the spec as markdown."
1795
+ )
1796
+ return project.llm_client.generate_code(
1797
+ expand_prompt,
1798
+ "You are a software architect writing a project specification.",
1799
+ )
1800
+
1801
+ if mode == BuildMode.REVIEW:
1802
+ return (
1803
+ f"# Review Spec\nReview and fix all issues found in the project.\n"
1804
+ f"Broken: {assessment.features.broken}\n"
1805
+ f"Stubs: {assessment.features.stubs}\n"
1806
+ f"TODOs: {len(assessment.features.todos)} items"
1807
+ )
1808
+
1809
+ return f"# Auto Spec\n{prompt}"
1810
+
1811
+ def _setup_env(self, project: Project) -> Optional[str]:
1812
+ """Initialize the project's env manager and return its activation prefix."""
1813
+ if project.env_manager:
1814
+ project.env_manager.setup()
1815
+ return project.env_manager.activate_command()
1816
+ return None
1817
+
1818
+ def _container_engine(self, project: Project):
1819
+ """Return the project's container engine if a container environment is
1820
+ configured and an engine is available, else None (gates run locally).
1821
+
1822
+ ``_setup_env`` has already called ``setup()``, so the engine is
1823
+ detected by the time gates run.
1824
+ """
1825
+ from misterdev.environments.container_env import (
1826
+ ContainerEnvironmentManager,
1827
+ )
1828
+
1829
+ env = project.env_manager
1830
+ if isinstance(env, ContainerEnvironmentManager):
1831
+ return env.engine()
1832
+ return None
1833
+
1834
+ def _project_file_map(self, project: Project) -> str:
1835
+ """The project's real file+symbol outline, for grounding decomposition.
1836
+
1837
+ Best-effort: builds the symbol graph (idempotent) and returns its project
1838
+ outline, or "" if topography is unavailable or errors — the decomposer
1839
+ then falls back to cautious path inference rather than failing.
1840
+ """
1841
+ topo = getattr(project, "topography", None)
1842
+ if topo is None:
1843
+ return ""
1844
+ try:
1845
+ topo.initialize()
1846
+ return topo.get_project_outline()
1847
+ except Exception as e:
1848
+ logger.warning(f"File map unavailable for decomposition (non-fatal): {e}")
1849
+ return ""
1850
+
1851
+ @staticmethod
1852
+ def _resolve_claim_file(root: Path, label: str, file_map: str) -> Optional[Path]:
1853
+ """Best-effort map a claim label to a real file for evidence.
1854
+
1855
+ A label that is itself a path wins. Otherwise its DISTINCTIVE identifier
1856
+ tokens (length >= 5, or CamelCase) are matched as WHOLE WORDS against the
1857
+ file+symbol map, longest first, and the first file that mentions one is
1858
+ used. The word-boundary + distinctiveness rules stop a generic token like
1859
+ "backend" from matching an unrelated ``backend_registry.py``. Returns None
1860
+ when nothing resolves — the caller then leaves the claim unverified rather
1861
+ than judging it against the wrong file.
1862
+ """
1863
+ if label:
1864
+ direct = root / label
1865
+ if direct.is_file():
1866
+ return direct
1867
+ tokens = sorted(
1868
+ {
1869
+ t
1870
+ for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", label or "")
1871
+ if len(t) >= 5 or any(c.isupper() for c in t)
1872
+ },
1873
+ key=len,
1874
+ reverse=True,
1875
+ )
1876
+ for token in tokens:
1877
+ pattern = re.compile(rf"\b{re.escape(token)}\b")
1878
+ for line in file_map.splitlines():
1879
+ if pattern.search(line):
1880
+ cand = root / line.split(":", 1)[0].strip()
1881
+ if cand.is_file():
1882
+ return cand
1883
+ return None
1884
+
1885
+ def _verify_completeness_claims(
1886
+ self, project: Project, assessment: ProjectAssessment, report: BuildReport
1887
+ ) -> None:
1888
+ """Drop completeness claims a second component refutes against the source.
1889
+
1890
+ The analyzer flags "incomplete"/"stub" items from a lossy overview, so it
1891
+ can mislabel deliberate design (graceful-degradation, platform no-ops,
1892
+ parity shims) as work. Before the spec and tasks are built, recheck each
1893
+ claim against the REAL file plus the verified build/test state with an
1894
+ independent verifier and prune only the ones it refutes WITH evidence;
1895
+ unsure / skip / timeout keeps the claim, so genuine work is never lost.
1896
+ No-op when disabled or when no LLM verifier is available.
1897
+ """
1898
+ if not get_setting(project.config, "orchestrator", "verify_claims"):
1899
+ return
1900
+ feats = assessment.features
1901
+ if not feats.incomplete and not feats.stubs:
1902
+ logger.info(
1903
+ "Completeness-claim verifier: no incomplete/stub claims flagged; "
1904
+ "nothing to verify."
1905
+ )
1906
+ return
1907
+
1908
+ from misterdev.analyzers.project_analyzer import (
1909
+ _health_ground_truth,
1910
+ )
1911
+ from misterdev.core.verification.claim_verifier import (
1912
+ Claim,
1913
+ verify_claims,
1914
+ )
1915
+
1916
+ root = project.path
1917
+ health = _health_ground_truth(assessment.health)
1918
+ file_map = self._project_file_map(project)
1919
+
1920
+ def read_body(path: Optional[Path]) -> str:
1921
+ if path is None or not path.is_file():
1922
+ return ""
1923
+ try:
1924
+ return path.read_text(encoding="utf-8", errors="replace")
1925
+ except OSError:
1926
+ return ""
1927
+
1928
+ # Build a claim ONLY when the claimed file's real source is in hand. Without
1929
+ # it the verifier would see just the "build passes" line — which the prompt
1930
+ # treats as grounds to refute — and could drop a genuine claim on no
1931
+ # evidence, so an unresolved claim is KEPT, unverified. `health` goes FIRST
1932
+ # so it survives the verifier's evidence truncation. `origin` is the
1933
+ # assessment object/string, so pruning is by identity and a shared or empty
1934
+ # label can't drop the wrong claim.
1935
+ entries = [] # (Claim, kind, origin)
1936
+ unverified = 0
1937
+ for fi in feats.incomplete:
1938
+ path = self._resolve_claim_file(root, fi.name, file_map)
1939
+ body = read_body(path)
1940
+ if not body:
1941
+ unverified += 1
1942
+ continue
1943
+ try:
1944
+ rel = path.relative_to(root)
1945
+ except ValueError:
1946
+ rel = path
1947
+ entries.append(
1948
+ (
1949
+ Claim(
1950
+ kind="incomplete",
1951
+ label=fi.name,
1952
+ description=f"{fi.description} (source file: {rel})",
1953
+ evidence=f"{health}\n\n{body}",
1954
+ ),
1955
+ "incomplete",
1956
+ fi,
1957
+ )
1958
+ )
1959
+ for sp in feats.stubs:
1960
+ if not isinstance(sp, str):
1961
+ continue
1962
+ body = read_body(root / sp)
1963
+ if not body:
1964
+ unverified += 1
1965
+ continue
1966
+ entries.append(
1967
+ (
1968
+ Claim(
1969
+ kind="stub",
1970
+ label=sp,
1971
+ description=f"flagged as a stub file (source file: {sp})",
1972
+ evidence=f"{health}\n\n{body}",
1973
+ ),
1974
+ "stub",
1975
+ sp,
1976
+ )
1977
+ )
1978
+
1979
+ if not entries:
1980
+ logger.info(
1981
+ "Completeness-claim verifier: no claims with readable source to "
1982
+ f"verify ({unverified} kept unverified)."
1983
+ )
1984
+ return
1985
+
1986
+ judge_model = (project.config.get("judge") or {}).get("model")
1987
+ timeout = get_setting(project.config, "orchestrator", "verify_claims_timeout")
1988
+ logger.info(
1989
+ f"Verifying {len(entries)} completeness claim(s) against the real source..."
1990
+ )
1991
+ try:
1992
+ verdicts = verify_claims(
1993
+ [claim for claim, _, _ in entries],
1994
+ llm_client=project.llm_client,
1995
+ model=judge_model,
1996
+ timeout=timeout,
1997
+ )
1998
+ except Exception as e: # the gate must never crash a build
1999
+ logger.warning(f"Completeness-claim verification failed (non-fatal): {e}")
2000
+ report.degraded_subsystems.append(f"Claim verifier: {e}")
2001
+ return
2002
+
2003
+ drop_incomplete: set[int] = set()
2004
+ drop_stubs: set[str] = set()
2005
+ for (claim, kind, origin), v in zip(entries, verdicts):
2006
+ if not v.refuted:
2007
+ continue
2008
+ if kind == "incomplete":
2009
+ drop_incomplete.add(id(origin))
2010
+ else:
2011
+ drop_stubs.add(origin)
2012
+ msg = f"Dropped phantom completeness claim '{claim.label}': {v.reason}"
2013
+ logger.info(msg)
2014
+ report.key_decisions.append(msg)
2015
+
2016
+ dropped = len(drop_incomplete) + len(drop_stubs)
2017
+ logger.info(
2018
+ f"Completeness-claim verification: {len(entries) - dropped} kept, "
2019
+ f"{dropped} dropped ({unverified} unverified)."
2020
+ )
2021
+ if dropped:
2022
+ feats.incomplete = [
2023
+ fi for fi in feats.incomplete if id(fi) not in drop_incomplete
2024
+ ]
2025
+ feats.stubs = [sp for sp in feats.stubs if sp not in drop_stubs]
2026
+
2027
+ def _resolve_targets(self, project: Project) -> list[dict]:
2028
+ """Explicit ``targets`` if declared, else auto-discovered when enabled.
2029
+
2030
+ Discovered targets are written back into ``project.config['targets']`` so
2031
+ the executor's per-task routing and per-target validation see them too.
2032
+ """
2033
+ explicit = project.config.get("targets") or []
2034
+ if explicit:
2035
+ return explicit
2036
+ if not get_setting(project.config, "orchestrator", "auto_targets"):
2037
+ return []
2038
+ from misterdev.core.planning.targets import discover_targets
2039
+
2040
+ discovered = discover_targets(str(project.path))
2041
+ if discovered:
2042
+ names = ", ".join(t["name"] for t in discovered)
2043
+ logger.info(
2044
+ f"Auto-discovered {len(discovered)} polyglot target(s): {names}"
2045
+ )
2046
+ project.config["targets"] = discovered
2047
+ return discovered
2048
+
2049
+ def _validate_targets(
2050
+ self, project: Project, env_activate: Optional[str]
2051
+ ) -> list[dict]:
2052
+ """Validate each declared target with its OWN toolchain, vs its baseline.
2053
+
2054
+ Closes the multi-target gap where the end-of-run GateKeeper only ran the
2055
+ top-level commands. Crucially this compares against each target's baseline
2056
+ (measured before the run, stored on ``project.target_baselines``), so a
2057
+ target that was ALREADY broken (e.g. a frontend with pre-existing errors)
2058
+ is not counted as a failure for a run that never touched it — only a
2059
+ genuine REGRESSION fails. Returns [] when no targets are declared, so
2060
+ single-target builds are unaffected.
2061
+ """
2062
+ targets = project.config.get("targets") or []
2063
+ if not targets:
2064
+ return []
2065
+ # getattr seam lets tests inject a fake runner; prod creates a real one.
2066
+ executor = getattr(self, "_validate_executor", None) or MarkdownPlanExecutor()
2067
+ target_baselines = getattr(project, "target_baselines", {}) or {}
2068
+ build_to = get_setting(project.config, "build", "build_timeout")
2069
+ test_to = get_setting(project.config, "build", "test_timeout")
2070
+ results: list[dict] = []
2071
+ for t in targets:
2072
+ gate_cmd = t.get("test_command") or t.get("build_command")
2073
+ if not gate_cmd:
2074
+ continue
2075
+ name = t.get("name") or t.get("path") or "?"
2076
+ tp = (t.get("path") or "").strip("/")
2077
+ run_dir = project.path / tp if tp else project.path
2078
+ timeout = test_to if t.get("test_command") else build_to
2079
+ after = self._suite_failures(
2080
+ project, executor, gate_cmd, timeout, cwd=run_dir
2081
+ )
2082
+ baseline = target_baselines.get(name)
2083
+ regressed = self._target_regressed(after, baseline)
2084
+ ok = not regressed
2085
+ detail = "ok" if ok else f"regressed (baseline={baseline}, after={after})"
2086
+ # Behavioral verification (opt-in): a frontend target may declare a
2087
+ # `web`/`vision` block to verify it actually RENDERS/works, not just
2088
+ # type-checks. Only run when the build/test gate is already clean.
2089
+ if ok and (t.get("web") or t.get("vision")):
2090
+ ok, rt_detail = self._run_target_runtime_gates(project, t, run_dir)
2091
+ if not ok:
2092
+ detail = rt_detail
2093
+ results.append({"name": name, "ok": ok, "detail": detail})
2094
+ return results
2095
+
2096
+ def _run_target_runtime_gates(
2097
+ self, project: Project, target: dict, run_dir
2098
+ ) -> tuple[bool, str]:
2099
+ """Run a target's opt-in web/vision behavioral gates in its directory.
2100
+
2101
+ Mirrors the GateKeeper's G4.7/G4.8 but scoped to a sub-project: the web
2102
+ gate renders + screenshots, the vision gate judges that screenshot. Both
2103
+ are best-effort and timeout-bounded — only a RED (a real failed check)
2104
+ fails the target; a SKIP (no browser/model/config) passes.
2105
+ """
2106
+ evidence = None
2107
+ web_cfg = target.get("web")
2108
+ if web_cfg:
2109
+ from misterdev.core.verification.web_verify import (
2110
+ run_web_gate,
2111
+ )
2112
+
2113
+ web = run_web_gate(run_dir, web_cfg)
2114
+ evidence = getattr(web, "evidence", None)
2115
+ if web.status == "red":
2116
+ return False, f"web verify failed ({web.reason or 'no detail'})"
2117
+ vision_cfg = target.get("vision")
2118
+ if vision_cfg:
2119
+ from misterdev.core.verification.vision_verify import (
2120
+ run_vision_gate,
2121
+ )
2122
+
2123
+ vc = dict(vision_cfg)
2124
+ if not vc.get("capture") and evidence:
2125
+ vc["capture"] = evidence
2126
+ vision = run_vision_gate(
2127
+ run_dir, vc or None, llm_client=getattr(project, "llm_client", None)
2128
+ )
2129
+ if vision.status == "red":
2130
+ return False, f"vision verify failed ({vision.reason or 'no detail'})"
2131
+ return True, "ok"
2132
+
2133
+ def _analyze(self, project: Project, env_activate: Optional[str]):
2134
+ """Phase 1 analysis with config-driven commands and timeouts.
2135
+
2136
+ Shared by build() and interactive_plan() so the analyzer's parameters
2137
+ (and any future config wiring) live in exactly one place.
2138
+ """
2139
+ # Build the project's symbol graph ONCE via its TopographyEngine and feed
2140
+ # the outline to the analyzer, instead of letting the source overview parse
2141
+ # a second throwaway graph. The engine's initialize() is idempotent, so the
2142
+ # later decomposition/file-map calls reuse this same graph.
2143
+ project_outline = self._project_file_map(project) or None
2144
+ return analyze_project(
2145
+ project.path,
2146
+ project.llm_client,
2147
+ build_command=project.config.get("build_command"),
2148
+ test_command=project.config.get("test_command"),
2149
+ lint_command=project.config.get("lint_command"),
2150
+ env_activate=env_activate,
2151
+ build_timeout=get_setting(project.config, "build", "build_timeout"),
2152
+ test_timeout=get_setting(project.config, "build", "test_timeout"),
2153
+ lint_timeout=get_setting(project.config, "build", "lint_timeout"),
2154
+ parallel=get_setting(project.config, "build", "parallel_analysis"),
2155
+ project_outline=project_outline,
2156
+ )
2157
+
2158
+ def _get_or_register(self, project_path: str | Path) -> Optional[Project]:
2159
+ project = self.registry.get_project(project_path)
2160
+ if not project:
2161
+ try:
2162
+ project = self.registry.register_project(project_path)
2163
+ except Exception as e:
2164
+ logger.error(f"Failed to register project at {project_path}: {e}")
2165
+ return None
2166
+ return project