forgex-cli 0.1.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 (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,269 @@
1
+ """Builder agent (spec section 9).
2
+
3
+ Receives task, plan, and research; implements task through the
4
+ permission-gated tool loop; reports changed files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ from forgex.agents.base import BaseAgent, prefetch_files
15
+ from forgex.config.schema import ForgexConfig
16
+ from forgex.models.router import ModelRouter
17
+ from forgex.orchestration.checkpoints import create_checkpoint
18
+ from forgex.orchestration.events import EventRecorder
19
+ from forgex.orchestration.stores import ProjectStores
20
+ from forgex.research.citations import format_notes
21
+ from forgex.sandbox.permissions import permission_set_for
22
+ from forgex.schemas.agent import AgentRole, BuilderResult
23
+ from forgex.schemas.error import ErrorRecord, ProjectError
24
+ from forgex.schemas.event import EventType
25
+ from forgex.schemas.plan import ArchitecturePlan
26
+ from forgex.schemas.research import ResearchNote
27
+ from forgex.schemas.task import Task, TaskStatus
28
+ from forgex.tools import ToolExecutor, build_tool_registry, create_context
29
+
30
+ logger = logging.getLogger("forgex.agents.builder")
31
+
32
+ MAX_PREFETCH_CHARS = 30_000
33
+
34
+
35
+ class BuilderAgent(BaseAgent):
36
+ role = AgentRole.BUILDER
37
+ prompt_name = "builder"
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ router: ModelRouter,
43
+ tools: ToolExecutor,
44
+ events: EventRecorder | None = None,
45
+ run_id: str | None = None,
46
+ max_prefetch_chars: int = MAX_PREFETCH_CHARS,
47
+ ) -> None:
48
+ super().__init__(router=router, events=events, run_id=run_id)
49
+ self._tools = tools
50
+ self._max_prefetch_chars = max_prefetch_chars
51
+
52
+ def _prefetch_existing(self, task: Task) -> str:
53
+ workspace = self._tools.context.workspace
54
+ planned = prefetch_files(workspace, task.files, budget=self._max_prefetch_chars)
55
+ remaining = self._max_prefetch_chars - len(planned)
56
+ if remaining <= 0:
57
+ return planned
58
+ extra = self._related_context(
59
+ workspace,
60
+ query=task.description,
61
+ seeds=list(task.files),
62
+ budget=remaining,
63
+ exclude=set(task.files),
64
+ )
65
+ return planned + ("\n\n" + extra if extra else "")
66
+
67
+ def _related_context(
68
+ self,
69
+ workspace: Path,
70
+ *,
71
+ query: str,
72
+ seeds: list[str],
73
+ budget: int,
74
+ exclude: set[str],
75
+ ) -> str:
76
+ try:
77
+ from forgex.context.manager import ContextManager
78
+
79
+ bundle = ContextManager(workspace).build_context(
80
+ query=query,
81
+ seeds=seeds,
82
+ budget=budget,
83
+ max_files=4,
84
+ exclude=exclude,
85
+ )
86
+ return bundle.render()
87
+ except Exception:
88
+ logger.warning("context retrieval failed; planned files only", exc_info=True)
89
+ return ""
90
+
91
+ async def execute(
92
+ self,
93
+ task: Task,
94
+ *,
95
+ plan: ArchitecturePlan | None = None,
96
+ research: list[ResearchNote] | None = None,
97
+ ) -> BuilderResult:
98
+ if self._events:
99
+ self._events.record(
100
+ EventType.AGENT_STARTED,
101
+ {"agent": self.role.value, "task": task.id},
102
+ run_id=self._run_id,
103
+ )
104
+
105
+ existing = self._prefetch_existing(task)
106
+ research_text = format_notes(research or [])
107
+
108
+ instruction = (
109
+ f"<task>\nID: {task.id}\nDescription: {task.description}\n"
110
+ f"Files: {', '.join(task.files) or 'none specified'}\n"
111
+ f"Acceptance Criteria: {', '.join(task.acceptance_criteria) or 'none'}\n</task>\n\n"
112
+ f"<plan>\nGoal: {plan.goal if plan else ''}\nArchitecture: {plan.architecture if plan else ''}\n</plan>\n\n"
113
+ f"<research>\n{research_text}\n</research>\n\n"
114
+ f"<existing_files>\n{existing or '(none found)'}\n</existing_files>\n\n"
115
+ "Implement this task using the tools. Inspect files first, make minimal targeted edits, and validate your work before reporting done."
116
+ )
117
+
118
+ loop_res = await self._tool_loop(instruction, self._tools, BuilderResult)
119
+ result: BuilderResult = loop_res.report
120
+
121
+ if self._events:
122
+ self._events.record(
123
+ EventType.AGENT_COMPLETED,
124
+ {
125
+ "agent": self.role.value,
126
+ "task": task.id,
127
+ "status": result.status,
128
+ "changed_files": result.changed_files,
129
+ },
130
+ run_id=self._run_id,
131
+ )
132
+ return result
133
+
134
+
135
+ def build_builder(
136
+ *,
137
+ config: ForgexConfig,
138
+ workspace: Path,
139
+ stores: ProjectStores,
140
+ router: ModelRouter | None = None,
141
+ approval: Callable[[str], bool] | None = None,
142
+ run_id: str | None = None,
143
+ ) -> BuilderAgent:
144
+ router = router or ModelRouter(config, usage=stores.usage)
145
+ tool_ctx = create_context(
146
+ workspace=workspace,
147
+ config=config,
148
+ events=stores.recorder,
149
+ approval=approval,
150
+ run_id=run_id,
151
+ agent=AgentRole.BUILDER,
152
+ )
153
+ tools = ToolExecutor(
154
+ registry=build_tool_registry(),
155
+ permissions=permission_set_for(AgentRole.BUILDER),
156
+ context=tool_ctx,
157
+ )
158
+ return BuilderAgent(
159
+ router=router,
160
+ tools=tools,
161
+ events=stores.recorder,
162
+ run_id=run_id,
163
+ )
164
+
165
+
166
+ async def execute_task(
167
+ task: Task,
168
+ *,
169
+ builder: BuilderAgent,
170
+ stores: ProjectStores,
171
+ config: ForgexConfig,
172
+ plan: ArchitecturePlan | None = None,
173
+ research: list[ResearchNote] | None = None,
174
+ ) -> BuilderResult:
175
+ state = stores.state.load()
176
+ if state is None:
177
+ raise ProjectError("Project state missing — run `forgex init` first.")
178
+
179
+ effective_plan = plan or state.architecture_plan
180
+
181
+ tasks = stores.tasks.load()
182
+ task_map = {t.id: t for t in tasks}
183
+ if task.id in task_map:
184
+ task = task_map[task.id]
185
+
186
+ # Verify dependencies
187
+ for dep_id in task.depends_on:
188
+ dep = task_map.get(dep_id)
189
+ if not dep or dep.status not in (TaskStatus.COMPLETED, TaskStatus.SKIPPED):
190
+ raise ProjectError(f"Dependency {dep_id} for {task.id} is not satisfied")
191
+
192
+ # Mark IN_PROGRESS
193
+ task.status = TaskStatus.IN_PROGRESS
194
+ task.attempts += 1
195
+ task.updated_at = datetime.now(UTC)
196
+ state.current_task = task.id
197
+ state.current_agent = AgentRole.BUILDER
198
+ stores.save_tasks(tasks, project_id=state.project_id)
199
+ stores.save_state(state)
200
+
201
+ create_checkpoint(
202
+ f"pre-{task.id}",
203
+ stores=stores,
204
+ description=f"Before executing {task.id}",
205
+ run_id=state.current_run_id,
206
+ )
207
+
208
+ stores.recorder.record(
209
+ EventType.TASK_STARTED,
210
+ {"task": task.id, "attempt": task.attempts},
211
+ run_id=state.current_run_id,
212
+ )
213
+
214
+ try:
215
+ result = await builder.execute(task, plan=effective_plan, research=research)
216
+ except KeyboardInterrupt:
217
+ raise
218
+ except Exception as exc:
219
+ task.status = TaskStatus.FAILED
220
+ task.result = f"builder error: {exc}"
221
+ task.updated_at = datetime.now(UTC)
222
+ if task.id not in state.failed_tasks:
223
+ state.failed_tasks.append(task.id)
224
+ state.errors.append(
225
+ ErrorRecord(
226
+ source="builder",
227
+ kind=type(exc).__name__,
228
+ message=str(exc),
229
+ run_id=state.current_run_id,
230
+ )
231
+ )
232
+ state.current_task = None
233
+ state.current_agent = None
234
+ stores.save_tasks(tasks, project_id=state.project_id)
235
+ stores.save_state(state)
236
+ stores.recorder.record(
237
+ EventType.TASK_COMPLETED,
238
+ {"task": task.id, "status": "failed", "error": str(exc)},
239
+ run_id=state.current_run_id,
240
+ )
241
+ raise
242
+
243
+ if result.status == "completed":
244
+ task.status = TaskStatus.COMPLETED
245
+ if task.id not in state.completed_tasks:
246
+ state.completed_tasks.append(task.id)
247
+ if task.id in state.failed_tasks:
248
+ state.failed_tasks.remove(task.id)
249
+ else:
250
+ task.status = TaskStatus.FAILED
251
+ if task.id not in state.failed_tasks:
252
+ state.failed_tasks.append(task.id)
253
+
254
+ task.result = result.summary
255
+ task.updated_at = datetime.now(UTC)
256
+ if result.changed_files:
257
+ state.changed_files = sorted(set(state.changed_files) | set(result.changed_files))
258
+
259
+ state.current_task = None
260
+ state.current_agent = None
261
+ stores.save_tasks(tasks, project_id=state.project_id)
262
+ stores.save_state(state)
263
+
264
+ stores.recorder.record(
265
+ EventType.TASK_COMPLETED,
266
+ {"task": task.id, "status": task.status.value, "changed_files": result.changed_files},
267
+ run_id=state.current_run_id,
268
+ )
269
+ return result
@@ -0,0 +1,334 @@
1
+ """Multi-model ensemble review (spec section 15).
2
+
3
+ N independent reviewer models examine the same diff in parallel, read-only;
4
+ findings are synthesized deterministically (corroboration counting = the judge);
5
+ the senior Expert Coder applies the justified fixes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import re
13
+ from typing import Any, Literal
14
+
15
+ from forgex.agents.base import BaseAgent, prefetch_files
16
+ from forgex.agents.expert_coder import _single_review, build_coder, resolve_review_scope
17
+ from forgex.config.schema import AgentModelBinding, ForgexConfig
18
+ from forgex.models.router import ModelRouter
19
+ from forgex.orchestration.checkpoints import create_checkpoint
20
+ from forgex.orchestration.stores import ProjectStores
21
+ from forgex.sandbox.permissions import Permission, PermissionSet
22
+ from forgex.schemas.agent import AgentRole, ExpertCoderResult, ReviewFinding, ReviewFindings
23
+ from forgex.schemas.error import ProjectError, ProviderError
24
+ from forgex.schemas.event import EventType
25
+ from forgex.schemas.task import Task
26
+ from forgex.tools import ToolExecutor, build_tool_registry, create_context
27
+
28
+ logger = logging.getLogger("forgex.ensemble")
29
+
30
+ _WORD_RE = re.compile(r"[a-z0-9_]{3,}")
31
+
32
+
33
+ def _tokenize(text: str) -> set[str]:
34
+ return set(_WORD_RE.findall(text.lower()))
35
+
36
+
37
+ def _jaccard(a: set[str], b: set[str]) -> float:
38
+ union = a | b
39
+ if not union:
40
+ return 0.0
41
+ return len(a & b) / len(union)
42
+
43
+
44
+ class BindingRouter:
45
+ """Delegates generate() to ModelRouter.generate_with() for a specific binding."""
46
+
47
+ def __init__(self, router: ModelRouter, provider: str, model: str) -> None:
48
+ self._router = router
49
+ self.provider = provider
50
+ self.model = model
51
+
52
+ def capabilities_for(self, agent: str):
53
+ return self._router.capabilities_for_binding(self.provider, self.model)
54
+
55
+ async def generate(self, agent: str, **kwargs) -> Any:
56
+ return await self._router.generate_with(
57
+ provider=self.provider,
58
+ model=self.model,
59
+ agent=agent,
60
+ **kwargs,
61
+ )
62
+
63
+ async def aclose(self) -> None:
64
+ pass
65
+
66
+
67
+ class ReviewerAgent(BaseAgent):
68
+ role = AgentRole.EXPERT_CODER
69
+ prompt_name = "reviewer"
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ router: Any,
75
+ tools: ToolExecutor,
76
+ label: str,
77
+ events=None,
78
+ run_id=None,
79
+ ) -> None:
80
+ super().__init__(router=router, events=events, run_id=run_id)
81
+ self._tools = tools
82
+ self.label = label
83
+
84
+ async def review(
85
+ self,
86
+ *,
87
+ diff: str,
88
+ changed_files: list[str] | None = None,
89
+ task: Task | None = None,
90
+ plan=None,
91
+ research=None,
92
+ errors=None,
93
+ ) -> ReviewFindings:
94
+ existing = prefetch_files(
95
+ self._tools.context.workspace,
96
+ changed_files or [],
97
+ budget=20_000,
98
+ max_files=8,
99
+ )
100
+
101
+ parts = [
102
+ f"<diff>\n{diff[:20000]}\n</diff>",
103
+ f"<changed_files>\n{', '.join(changed_files or [])}\n</changed_files>",
104
+ ]
105
+ if task:
106
+ parts.append(f"<task>\n{task.id}: {task.description}\n</task>")
107
+ if plan:
108
+ parts.append(f"<plan>\nGoal: {plan.goal}\n</plan>")
109
+ if existing:
110
+ parts.append(f"<existing_files>\n{existing}\n</existing_files>")
111
+
112
+ parts.append(
113
+ "Review the diff above as an independent critic. Report findings with severity and file. Do not edit files."
114
+ )
115
+
116
+ loop_res = await self._tool_loop("\n\n".join(parts), self._tools, ReviewFindings)
117
+ return loop_res.report
118
+
119
+
120
+ def synthesize(
121
+ reviews: list[ReviewFindings],
122
+ ) -> tuple[Literal["ok", "needs_changes"], list[ReviewFinding]]:
123
+ """Synthesize findings across reviewers with corroboration clustering."""
124
+ all_findings: list[tuple[ReviewFinding, int]] = []
125
+ for r_idx, rev in enumerate(reviews):
126
+ for f in rev.findings:
127
+ all_findings.append((f, r_idx))
128
+
129
+ clusters: list[list[tuple[ReviewFinding, int]]] = []
130
+ for f, r_idx in all_findings:
131
+ f_toks = _tokenize(f.issue + " " + (f.suggestion or ""))
132
+ matched = False
133
+ for c in clusters:
134
+ c_toks = _tokenize(c[0][0].issue + " " + (c[0][0].suggestion or ""))
135
+ if _jaccard(f_toks, c_toks) >= 0.4:
136
+ c.append((f, r_idx))
137
+ matched = True
138
+ break
139
+ if not matched:
140
+ clusters.append([(f, r_idx)])
141
+
142
+ confirmed: list[ReviewFinding] = []
143
+ for c in clusters:
144
+ unique_reviewers = {r_idx for _, r_idx in c}
145
+ lead = c[0][0]
146
+ if len(unique_reviewers) >= 2 or lead.severity == "critical" or len(reviews) == 1:
147
+ confirmed.append(lead)
148
+
149
+ needs_changes_count = sum(1 for r in reviews if r.verdict == "needs_changes")
150
+ majority_needs_changes = needs_changes_count > (len(reviews) // 2)
151
+
152
+ has_significant_finding = any(f.severity in ("critical", "major") for f in confirmed)
153
+
154
+ verdict: Literal["ok", "needs_changes"] = (
155
+ "needs_changes" if (has_significant_finding or majority_needs_changes) else "ok"
156
+ )
157
+ return verdict, confirmed
158
+
159
+
160
+ def resolve_reviewer_bindings(config: ForgexConfig) -> list[AgentModelBinding]:
161
+ if config.ensemble.bindings:
162
+ return config.ensemble.bindings[: config.ensemble.reviewers]
163
+
164
+ bindings: list[AgentModelBinding] = []
165
+ ec = config.models.expert_coder
166
+ bindings.append(AgentModelBinding(provider=ec.provider, model=ec.model))
167
+
168
+ if config.fallback.enabled:
169
+ for fb_p in config.fallback.providers:
170
+ if fb_p == ec.provider:
171
+ continue
172
+ prov_cfg = config.providers.get(fb_p)
173
+ if prov_cfg and prov_cfg.default_model:
174
+ bindings.append(AgentModelBinding(provider=fb_p, model=prov_cfg.default_model))
175
+ if len(bindings) >= config.ensemble.reviewers:
176
+ break
177
+
178
+ return bindings
179
+
180
+
181
+ def format_synthesis(verdict: str, findings: list[ReviewFinding]) -> str:
182
+ lines = [f"Ensemble review synthesis: verdict={verdict}"]
183
+ if findings:
184
+ lines.append("Corroborated findings to address:")
185
+ for f in findings:
186
+ file_part = f" ({f.file})" if f.file else ""
187
+ sugg_part = f" -> Suggestion: {f.suggestion}" if f.suggestion else ""
188
+ lines.append(f"- [{f.severity.upper()}]{file_part} {f.issue}{sugg_part}")
189
+ else:
190
+ lines.append("No corroborated issues found.")
191
+ return "\n".join(lines)
192
+
193
+
194
+ async def ensemble_review(
195
+ *,
196
+ stores: ProjectStores,
197
+ config: ForgexConfig,
198
+ task_id: str | None = None,
199
+ base_commit: str | None = None,
200
+ applier: Any | None = None,
201
+ approval: Any = None,
202
+ ) -> ExpertCoderResult | None:
203
+ bindings = resolve_reviewer_bindings(config)
204
+ if len(bindings) < 2:
205
+ logger.warning("Fewer than 2 ensemble bindings available; falling back to single review")
206
+ return await _single_review(
207
+ stores=stores,
208
+ config=config,
209
+ task_id=task_id,
210
+ base_commit=base_commit,
211
+ coder=applier,
212
+ approval=approval,
213
+ )
214
+
215
+ scope = resolve_review_scope(stores=stores, task_id=task_id, base_commit=base_commit)
216
+ if scope is None:
217
+ return None
218
+ task, diff, changed = scope
219
+
220
+ state = stores.state.load()
221
+ if state is None:
222
+ raise ProjectError("Project state missing")
223
+
224
+ create_checkpoint(
225
+ f"pre-ensemble{'-' + task_id if task_id else ''}",
226
+ stores=stores,
227
+ description="Before ensemble review",
228
+ run_id=state.current_run_id,
229
+ )
230
+
231
+ router = ModelRouter(config, usage=stores.usage)
232
+
233
+ async def _run_one(binding: AgentModelBinding, idx: int) -> ReviewFindings:
234
+ label = f"reviewer_{idx}_{binding.provider}"
235
+ b_router = BindingRouter(router, binding.provider, binding.model or "")
236
+ tool_ctx = create_context(
237
+ workspace=stores.root,
238
+ config=config,
239
+ events=stores.recorder,
240
+ run_id=state.current_run_id,
241
+ agent=AgentRole.EXPERT_CODER,
242
+ )
243
+ tools = ToolExecutor(
244
+ registry=build_tool_registry(),
245
+ permissions=PermissionSet(allow={Permission.READ, Permission.GIT, Permission.TEST}),
246
+ context=tool_ctx,
247
+ )
248
+ agent = ReviewerAgent(
249
+ router=b_router,
250
+ tools=tools,
251
+ label=label,
252
+ events=stores.recorder,
253
+ run_id=state.current_run_id,
254
+ )
255
+ return await agent.review(
256
+ diff=diff,
257
+ changed_files=changed,
258
+ task=task,
259
+ plan=state.architecture_plan,
260
+ research=state.research,
261
+ errors=state.errors,
262
+ )
263
+
264
+ tasks = [_run_one(b, i) for i, b in enumerate(bindings)]
265
+ results = await asyncio.gather(*tasks, return_exceptions=True)
266
+
267
+ valid_reviews: list[ReviewFindings] = []
268
+ failures = 0
269
+ for r in results:
270
+ if isinstance(r, BaseException):
271
+ failures += 1
272
+ logger.warning("Ensemble reviewer failed: %s", r)
273
+ else:
274
+ valid_reviews.append(r)
275
+
276
+ if not valid_reviews:
277
+ await router.aclose()
278
+ raise ProviderError("All ensemble reviewers failed")
279
+
280
+ verdict, confirmed_findings = synthesize(valid_reviews)
281
+
282
+ stores.recorder.record(
283
+ EventType.ENSEMBLE_COMPLETED,
284
+ {
285
+ "reviewers": len(bindings),
286
+ "failures": failures,
287
+ "verdict": verdict,
288
+ "confirmed_findings": len(confirmed_findings),
289
+ },
290
+ run_id=state.current_run_id,
291
+ )
292
+
293
+ if verdict == "ok":
294
+ await router.aclose()
295
+ return ExpertCoderResult(
296
+ status="completed",
297
+ summary=f"[ensemble {len(valid_reviews)}/{len(bindings)}] approved without changes",
298
+ changed_files=[],
299
+ improvements=[],
300
+ issues_found=[f.issue for f in confirmed_findings],
301
+ unfixed_issues=[],
302
+ )
303
+
304
+ # Apply justified fixes
305
+ synth_text = format_synthesis(verdict, confirmed_findings)
306
+ senior = applier or build_coder(
307
+ config=config,
308
+ workspace=stores.root,
309
+ stores=stores,
310
+ router=router,
311
+ approval=approval,
312
+ run_id=state.current_run_id,
313
+ )
314
+ owns_senior = applier is None
315
+ try:
316
+ applied = await senior.review(
317
+ task=task,
318
+ plan=state.architecture_plan,
319
+ research=state.research,
320
+ diff=diff,
321
+ changed_files=changed,
322
+ errors=state.errors,
323
+ extra_context=synth_text,
324
+ )
325
+ finally:
326
+ if owns_senior:
327
+ await senior.aclose()
328
+ await router.aclose()
329
+
330
+ if applied.changed_files:
331
+ state.changed_files = sorted(set(state.changed_files) | set(applied.changed_files))
332
+ stores.save_state(state)
333
+
334
+ return applied