code-context-control 2.28.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 (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
cli/tools/agent.py ADDED
@@ -0,0 +1,1165 @@
1
+ """c3_agent — Compound workflow agent for multi-step C3 pipelines in a single call."""
2
+
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from concurrent.futures import TimeoutError as FuturesTimeout
6
+ from pathlib import Path
7
+
8
+ from core import count_tokens
9
+
10
+ # ── Progress logging ─────────────────────────────────────────────────────────
11
+
12
+ def _progress_ticker(svc, message, interval=3.0):
13
+ """Context manager: sends animated dot-progress ticks during a slow step.
14
+
15
+ Emits one MCP log notification per interval with rotating dots and elapsed
16
+ time, so the user sees activity instead of silence during long waits.
17
+
18
+ Usage:
19
+ _log_progress(svc, "[3/3] Waiting for Gemini...")
20
+ with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
21
+ result = slow_call()
22
+ """
23
+ import threading
24
+ from contextlib import contextmanager
25
+
26
+ @contextmanager
27
+ def _ctx():
28
+ stop = threading.Event()
29
+ dots_cycle = [". ", ".. ", "...", " .."]
30
+
31
+ def _tick():
32
+ i = 0
33
+ t0 = time.time()
34
+ while not stop.wait(interval):
35
+ elapsed = int(time.time() - t0)
36
+ _log_progress(svc, f"{message}{dots_cycle[i % len(dots_cycle)]} {elapsed}s")
37
+ i += 1
38
+
39
+ t = threading.Thread(target=_tick, daemon=True)
40
+ t.start()
41
+ try:
42
+ yield
43
+ finally:
44
+ stop.set()
45
+ t.join(timeout=1)
46
+
47
+ return _ctx()
48
+
49
+
50
+ def _log_progress(svc, message):
51
+ """Emit workflow progress: MCP log notification + .c3/agent_progress.jsonl file."""
52
+ # Live MCP notification (shown in Claude Code during tool execution)
53
+ cb = getattr(svc, "_agent_progress_cb", None)
54
+ if cb:
55
+ try:
56
+ cb(message)
57
+ except Exception:
58
+ pass
59
+ # Persistent file log (for hub UI / external readers)
60
+ try:
61
+ import json as _json
62
+ progress_file = Path(svc.project_path) / ".c3" / "agent_progress.jsonl"
63
+ progress_file.parent.mkdir(parents=True, exist_ok=True)
64
+ with open(progress_file, "a", encoding='utf-8') as f:
65
+ f.write(_json.dumps({"ts": time.time(), "step": message}) + "\n")
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def _delegate_failure_reason(steps_log):
71
+ """Extract last delegate failure reason from steps_log, if any."""
72
+ for entry in reversed(steps_log):
73
+ if "(skipped:" in entry or "(timeout" in entry or "(error" in entry or "(idle_timeout" in entry:
74
+ return entry
75
+ return None
76
+
77
+
78
+ # ── Workflow registry ─────────────────────────────────────────────────────────
79
+
80
+ AGENT_WORKFLOWS = {
81
+ "review_changes": {
82
+ "description": "Review staged/unstaged git changes with structural context",
83
+ "steps": ["git_diff", "compress_changed", "delegate_review"],
84
+ },
85
+ "prepare_context": {
86
+ "description": "Build a compressed context package for specified files/scope",
87
+ "steps": ["resolve_files", "compress_all", "bundle"],
88
+ },
89
+ "investigate": {
90
+ "description": "Investigate an error or issue using search + compress + activity",
91
+ "steps": ["search_code", "compress_hits", "activity_context", "delegate_diagnose"],
92
+ },
93
+ "preflight": {
94
+ "description": "Pre-edit check: validate + compress (parallel) + recent activity for target files",
95
+ "steps": ["validate+compress(parallel)", "activity_summary"],
96
+ },
97
+ "validate_compress": {
98
+ "description": "Validate and compress a file set in one call — both run in parallel",
99
+ "steps": ["validate+compress(parallel)"],
100
+ },
101
+ "codex_review": {
102
+ "description": "Review staged/unstaged git changes using Codex CLI for deeper analysis",
103
+ "steps": ["git_diff", "compress_changed", "codex_delegate_review"],
104
+ },
105
+ "consensus_review": {
106
+ "description": "Cross-model consensus: run Codex + Gemini in parallel, diff findings",
107
+ "steps": ["git_diff", "compress_changed", "parallel(codex_review, gemini_review)", "merge_findings"],
108
+ },
109
+ "codex_test_gen": {
110
+ "description": "Generate and run tests via Codex workspace-write sandbox",
111
+ "steps": ["identify_changes", "compress_targets", "codex_generate_tests"],
112
+ },
113
+ "gemini_review": {
114
+ "description": "Review staged/unstaged git changes using Gemini CLI",
115
+ "steps": ["git_diff", "compress_changed", "gemini_delegate_review"],
116
+ },
117
+ "tri_consensus": {
118
+ "description": "Cloud consensus: Codex + Gemini in parallel, merged findings",
119
+ "steps": ["git_diff", "compress_changed", "parallel(codex, gemini)", "merge_findings"],
120
+ },
121
+ }
122
+
123
+
124
+ def handle_agent(workflow: str, scope: str, context: str,
125
+ svc, finalize) -> str:
126
+ """Execute a compound workflow."""
127
+ if workflow == "available":
128
+ wf_list = "\n".join(
129
+ f"- {k}: {v['description']}" for k, v in AGENT_WORKFLOWS.items()
130
+ )
131
+ return finalize("c3_agent", {"workflow": "available"},
132
+ f"[agent:available] {len(AGENT_WORKFLOWS)} workflows\n{wf_list}",
133
+ f"{len(AGENT_WORKFLOWS)} workflows")
134
+
135
+ wdef = AGENT_WORKFLOWS.get(workflow)
136
+ if not wdef:
137
+ available = ", ".join(AGENT_WORKFLOWS.keys())
138
+ return finalize("c3_agent", {"workflow": workflow},
139
+ f"[agent:error] Unknown workflow '{workflow}'. Available: {available}",
140
+ "error")
141
+
142
+ t0 = time.time()
143
+ steps_log = []
144
+
145
+ try:
146
+ if workflow == "review_changes":
147
+ result = _wf_review_changes(scope, context, svc, steps_log)
148
+ elif workflow == "codex_review":
149
+ result = _wf_codex_review(scope, context, svc, steps_log)
150
+ elif workflow == "consensus_review":
151
+ result = _wf_consensus_review(scope, context, svc, steps_log)
152
+ elif workflow == "codex_test_gen":
153
+ result = _wf_codex_test_gen(scope, context, svc, steps_log)
154
+ elif workflow == "gemini_review":
155
+ result = _wf_gemini_review(scope, context, svc, steps_log)
156
+ elif workflow == "tri_consensus":
157
+ result = _wf_tri_consensus(scope, context, svc, steps_log)
158
+ elif workflow == "prepare_context":
159
+ result = _wf_prepare_context(scope, context, svc, steps_log)
160
+ elif workflow == "investigate":
161
+ result = _wf_investigate(scope, context, svc, steps_log)
162
+ elif workflow == "preflight":
163
+ result = _wf_preflight(scope, context, svc, steps_log)
164
+ elif workflow == "validate_compress":
165
+ result = _wf_validate_compress(scope, context, svc, steps_log)
166
+ else:
167
+ result = f"[agent:error] Workflow '{workflow}' not implemented"
168
+ except Exception as e:
169
+ result = f"[agent:error] {workflow} failed: {e}"
170
+
171
+ elapsed = time.time() - t0
172
+ steps_summary = " → ".join(steps_log) if steps_log else "no steps"
173
+ header = f"[agent:{workflow}] {elapsed:.1f}s | {steps_summary}\n\n"
174
+
175
+ return finalize("c3_agent", {"workflow": workflow, "scope": scope},
176
+ header + result,
177
+ f"{workflow} {elapsed:.1f}s")
178
+
179
+
180
+ # ── Workflow implementations ──────────────────────────────────────────────────
181
+
182
+ def _wf_review_changes(scope, context, svc, steps_log):
183
+ """Review git changes: diff → compress changed files → delegate review."""
184
+
185
+ # Workflow-level deadline: abort if total time exceeds this.
186
+ _workflow_deadline = time.time() + 90 # 90 seconds max for entire workflow
187
+
188
+ def _check_deadline(step_name: str):
189
+ if time.time() > _workflow_deadline:
190
+ raise TimeoutError(f"[{step_name}] workflow deadline exceeded (90s)")
191
+
192
+ try:
193
+ return _wf_review_changes_inner(scope, context, svc, steps_log,
194
+ _workflow_deadline, _check_deadline)
195
+ except TimeoutError as e:
196
+ _log_progress(svc, f"[timeout] {e}")
197
+ return f"[workflow:timeout] {e}\nSteps completed: {' → '.join(steps_log)}"
198
+
199
+
200
+ def _wf_review_changes_inner(scope, context, svc, steps_log,
201
+ _workflow_deadline, _check_deadline):
202
+ import subprocess
203
+
204
+ # Step 1: Get git diff
205
+ _log_progress(svc, "[1/3] Running git diff...")
206
+ diff_cmd = ["git", "diff"]
207
+ if scope == "staged":
208
+ diff_cmd.append("--cached")
209
+ elif scope == "HEAD":
210
+ diff_cmd.extend(["HEAD~1", "HEAD"])
211
+ # default: unstaged changes
212
+
213
+ try:
214
+ r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
215
+ stdin=subprocess.DEVNULL,
216
+ cwd=svc.project_path)
217
+ diff_text = r.stdout.strip()
218
+ except Exception as e:
219
+ return f"[step:git_diff] Failed: {e}"
220
+
221
+ if not diff_text:
222
+ return "[step:git_diff] No changes found"
223
+ steps_log.append(f"diff({len(diff_text)}ch)")
224
+
225
+ # Step 2: Extract changed file paths
226
+ changed_files = set()
227
+ for line in diff_text.split("\n"):
228
+ if line.startswith("diff --git"):
229
+ parts = line.split(" b/")
230
+ if len(parts) >= 2:
231
+ changed_files.add(parts[-1])
232
+
233
+ # Start Gemini subprocess early so its ~9s MCP startup overlaps with compress
234
+ dcfg = svc.delegate_config or {}
235
+ gemini_proc = None
236
+ if dcfg.get("enabled", True) and dcfg.get("gemini_enabled", False):
237
+ import cli.tools.delegate as _dm
238
+ from cli.tools.delegate import GEMINI_MODELS, _is_gemini_on_path, _start_gemini_early
239
+ # Prefer cached availability (avoids shutil.which on slow network-path systems)
240
+ _gem_ok = (_dm._gemini_available is True) or (
241
+ _dm._gemini_available is None and _is_gemini_on_path()
242
+ )
243
+ if _gem_ok and _dm._gemini_available is not False:
244
+ gdef = GEMINI_MODELS.get("review", GEMINI_MODELS.get("ask", {}))
245
+ model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
246
+ _log_progress(svc, f"[2/3] Compressing {len(changed_files)} file(s) + starting Gemini...")
247
+ gemini_proc = _start_gemini_early(
248
+ model=model,
249
+ timeout=int(dcfg.get("gemini_timeout", 45)),
250
+ idle_timeout=15,
251
+ cwd=str(svc.project_path),
252
+ )
253
+ else:
254
+ _log_progress(svc, "[2/3] Compressing changed files...")
255
+ else:
256
+ _log_progress(svc, "[2/3] Compressing changed files...")
257
+
258
+ with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
259
+ maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
260
+
261
+ _check_deadline("compress")
262
+
263
+ # Step 3: Feed prompt to already-running Gemini (MCP startup overlapped with compress)
264
+ # Clamp delegate timeout to remaining workflow time to prevent overshoot.
265
+ _remaining = max(10, int(_workflow_deadline - time.time()))
266
+ review_context = f"Git diff:\n{diff_text[:3000]}\n\nStructural maps:\n{maps}"
267
+ if context:
268
+ review_context += f"\n\nUser context: {context}"
269
+
270
+ if gemini_proc is not None:
271
+ _log_progress(svc, "[3/3] Sending prompt to Gemini (MCP ready)...")
272
+ from cli.tools.delegate import _finish_gemini_early
273
+ with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
274
+ output, ok, _ = _finish_gemini_early(
275
+ gemini_proc, "Review these changes for issues", review_context,
276
+ timeout=min(int(dcfg.get("gemini_timeout", 45)), _remaining),
277
+ idle_timeout=15,
278
+ )
279
+ if ok and output:
280
+ steps_log.append("gemini(review,early_start)")
281
+ delegate_result = output
282
+ else:
283
+ if output and output.startswith("["):
284
+ steps_log.append(f"gemini({output.split(']')[0]}])".replace("[", ""))
285
+ else:
286
+ steps_log.append("gemini(error:no_output)")
287
+ delegate_result = ""
288
+ else:
289
+ _log_progress(svc, "[3/3] Delegating review to Gemini...")
290
+ with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
291
+ delegate_result = _try_delegate(svc, "review", "Review these changes for issues",
292
+ review_context, steps_log, prefer_gemini=True)
293
+
294
+ parts = [f"--- Changed files ({len(changed_files)}) ---"]
295
+ for f in sorted(changed_files):
296
+ parts.append(f" {f}")
297
+ parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
298
+ # Truncate diff for response
299
+ if count_tokens(diff_text) > 800:
300
+ parts.append(diff_text[:2000] + "\n... [truncated]")
301
+ else:
302
+ parts.append(diff_text)
303
+ if maps:
304
+ parts.append(f"\n--- Structural maps ---\n{maps}")
305
+ if delegate_result:
306
+ parts.append(f"\n--- AI Review ---\n{delegate_result}")
307
+ else:
308
+ reason = _delegate_failure_reason(steps_log)
309
+ parts.append(f"\n--- AI Review skipped: {reason or 'no backend available'} ---")
310
+
311
+ return "\n".join(parts)
312
+
313
+
314
+ def _wf_codex_review(scope, context, svc, steps_log):
315
+ """Review git changes using Codex CLI for deeper analysis."""
316
+ import subprocess
317
+
318
+ # Step 1: Get git diff (same as review_changes)
319
+ _log_progress(svc, "[1/3] Running git diff...")
320
+ diff_cmd = ["git", "diff"]
321
+ if scope == "staged":
322
+ diff_cmd.append("--cached")
323
+ elif scope == "HEAD":
324
+ diff_cmd.extend(["HEAD~1", "HEAD"])
325
+
326
+ try:
327
+ r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
328
+ cwd=svc.project_path)
329
+ diff_text = r.stdout.strip()
330
+ except Exception as e:
331
+ return f"[step:git_diff] Failed: {e}"
332
+
333
+ if not diff_text:
334
+ return "[step:git_diff] No changes found"
335
+ steps_log.append(f"diff({len(diff_text)}ch)")
336
+
337
+ # Step 2: Compress changed files
338
+ changed_files = set()
339
+ for line in diff_text.split("\n"):
340
+ if line.startswith("diff --git"):
341
+ parts = line.split(" b/")
342
+ if len(parts) >= 2:
343
+ changed_files.add(parts[-1])
344
+
345
+ _log_progress(svc, f"[2/3] Compressing {len(changed_files)} changed files...")
346
+ with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
347
+ maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
348
+
349
+ # Step 3: Delegate to Codex (prefer_codex=True)
350
+ _log_progress(svc, "[3/3] Delegating review to Codex...")
351
+ review_context = f"Git diff:\n{diff_text[:6000]}\n\nStructural maps:\n{maps}"
352
+ if context:
353
+ review_context += f"\n\nUser context: {context}"
354
+
355
+ with _progress_ticker(svc, "[3/3] Waiting for Codex", interval=3.0):
356
+ delegate_result = _try_delegate(svc, "review", "Review these changes for issues, regressions, and missing tests",
357
+ review_context, steps_log, prefer_codex=True)
358
+
359
+ parts = [f"--- Changed files ({len(changed_files)}) ---"]
360
+ for f in sorted(changed_files):
361
+ parts.append(f" {f}")
362
+ parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
363
+ if count_tokens(diff_text) > 800:
364
+ parts.append(diff_text[:2000] + "\n... [truncated]")
365
+ else:
366
+ parts.append(diff_text)
367
+ if maps:
368
+ parts.append(f"\n--- Structural maps ---\n{maps}")
369
+ if delegate_result:
370
+ parts.append(f"\n--- Codex Review ---\n{delegate_result}")
371
+ else:
372
+ reason = _delegate_failure_reason(steps_log)
373
+ parts.append(f"\n--- Codex Review skipped: {reason or 'no backend available'} ---")
374
+
375
+ return "\n".join(parts)
376
+
377
+
378
+ def _wf_consensus_review(scope, context, svc, steps_log):
379
+ """Cross-model consensus: run Ollama + Codex review in parallel, merge findings."""
380
+ import subprocess
381
+
382
+ # Step 1: Get git diff
383
+ _log_progress(svc, "[1/4] Running git diff...")
384
+ diff_cmd = ["git", "diff"]
385
+ if scope == "staged":
386
+ diff_cmd.append("--cached")
387
+ elif scope == "HEAD":
388
+ diff_cmd.extend(["HEAD~1", "HEAD"])
389
+
390
+ try:
391
+ r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
392
+ cwd=svc.project_path)
393
+ diff_text = r.stdout.strip()
394
+ except Exception as e:
395
+ return f"[step:git_diff] Failed: {e}"
396
+
397
+ if not diff_text:
398
+ return "[step:git_diff] No changes found"
399
+ steps_log.append(f"diff({len(diff_text)}ch)")
400
+
401
+ # Step 2: Compress changed files
402
+ changed_files = set()
403
+ for line in diff_text.split("\n"):
404
+ if line.startswith("diff --git"):
405
+ parts = line.split(" b/")
406
+ if len(parts) >= 2:
407
+ changed_files.add(parts[-1])
408
+ _log_progress(svc, f"[2/4] Compressing {len(changed_files)} changed files...")
409
+ with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
410
+ maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
411
+
412
+ review_context = f"Git diff:\n{diff_text[:4000]}\n\nStructural maps:\n{maps}"
413
+ if context:
414
+ review_context += f"\n\nUser context: {context}"
415
+ review_task = "Review these changes for bugs, regressions, and missing tests. Be concise."
416
+
417
+ # Step 3: Run Codex and Gemini in parallel
418
+ _log_progress(svc, "[3/4] Delegating to Codex + Gemini in parallel...")
419
+ codex_result = ""
420
+ gemini_result = ""
421
+ codex_steps = []
422
+ gemini_steps = []
423
+
424
+ def _run_codex():
425
+ return _try_delegate(svc, "review", review_task, review_context, codex_steps, prefer_codex=True)
426
+
427
+ def _run_gemini():
428
+ return _try_delegate(svc, "review", review_task, review_context, gemini_steps, prefer_gemini=True)
429
+
430
+ with ThreadPoolExecutor(max_workers=2) as pool:
431
+ fut_codex = pool.submit(_run_codex)
432
+ fut_gemini = pool.submit(_run_gemini)
433
+ try:
434
+ codex_result = fut_codex.result(timeout=100)
435
+ steps_log.extend(codex_steps)
436
+ except Exception:
437
+ codex_result = "[timeout/error]"
438
+ try:
439
+ gemini_result = fut_gemini.result(timeout=100)
440
+ steps_log.extend(gemini_steps)
441
+ except Exception:
442
+ gemini_result = "[timeout/error]"
443
+
444
+ # Step 4: Merge findings
445
+ parts = [f"--- Changed files ({len(changed_files)}) ---"]
446
+ for f in sorted(changed_files):
447
+ parts.append(f" {f}")
448
+ parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
449
+ if count_tokens(diff_text) > 800:
450
+ parts.append(diff_text[:2000] + "\n... [truncated]")
451
+ else:
452
+ parts.append(diff_text)
453
+ if maps:
454
+ parts.append(f"\n--- Structural maps ---\n{maps}")
455
+
456
+ parts.append("\n" + "=" * 60)
457
+ parts.append("CONSENSUS REVIEW — Cloud backend analyses")
458
+ parts.append("=" * 60)
459
+
460
+ backends = [
461
+ ("Codex (cloud/GPT)", codex_result),
462
+ ("Gemini (cloud/Google)", gemini_result),
463
+ ]
464
+ available_count = 0
465
+ for label, result in backends:
466
+ if result and result != "[timeout/error]":
467
+ parts.append(f"\n--- {label} ---\n{result}")
468
+ available_count += 1
469
+ else:
470
+ parts.append(f"\n--- {label} ---\n[unavailable or no response]")
471
+
472
+ _log_progress(svc, "[4/4] Merging consensus results...")
473
+ if available_count == 2:
474
+ parts.append("\n--- Consensus ---")
475
+ parts.append("Both models provided reviews. Compare findings above for agreement/divergence.")
476
+ elif available_count == 1:
477
+ parts.append("\n--- Note: Only one backend responded ---")
478
+ else:
479
+ reasons = [_delegate_failure_reason(codex_steps), _delegate_failure_reason(gemini_steps)]
480
+ detail = ", ".join(r for r in reasons if r) or "check delegate configuration"
481
+ parts.append(f"\n--- No backends available: {detail} ---")
482
+
483
+ return "\n".join(parts)
484
+
485
+
486
+ def _wf_codex_test_gen(scope, context, svc, steps_log):
487
+ """Generate tests via Codex CLI with workspace-write sandbox."""
488
+ from cli.tools.delegate import _codex_available, _run_codex, check_codex
489
+
490
+ # Verify Codex
491
+ if _codex_available is None:
492
+ check_codex()
493
+ from cli.tools.delegate import _codex_available as avail
494
+ if not avail:
495
+ return "[codex_test_gen:error] Codex CLI not available"
496
+
497
+ dcfg = svc.delegate_config or {}
498
+ if not dcfg.get("codex_enabled", False):
499
+ return "[codex_test_gen:error] Codex not enabled in config"
500
+
501
+ # Resolve target files from scope
502
+ files = []
503
+ if scope and ("," in scope or scope.endswith((".py", ".js", ".ts", ".go", ".rs", ".java"))):
504
+ files = [f.strip() for f in scope.split(",") if f.strip()]
505
+ else:
506
+ # Use edit ledger to find recently edited files
507
+ try:
508
+ recent = svc.edit_ledger.get_history(limit=5)
509
+ files = list(dict.fromkeys(e.get("file", "") for e in recent if e.get("file")))[:5]
510
+ except Exception:
511
+ pass
512
+
513
+ if not files:
514
+ return "[codex_test_gen:error] No target files found. Provide file paths in scope or edit files first."
515
+
516
+ steps_log.append(f"targets({len(files)})")
517
+
518
+ # Compress targets for context
519
+ maps = _parallel_compress(files, svc, steps_log)
520
+
521
+ # Build test generation prompt
522
+ file_list = ", ".join(files)
523
+ task = (
524
+ f"Generate focused unit tests for the following files: {file_list}. "
525
+ "Write tests that maximize defect coverage with minimal redundancy. "
526
+ "Use the project's existing test framework and conventions. "
527
+ "Write the test files to disk."
528
+ )
529
+ gen_context = f"Target files:\n{file_list}\n\nStructural maps:\n{maps}"
530
+ if context:
531
+ gen_context += f"\n\nAdditional context: {context}"
532
+
533
+ model = dcfg.get("codex_default_model", "gpt-5.3-codex-spark")
534
+ timeout = int(dcfg.get("codex_timeout", 180))
535
+
536
+ steps_log.append("codex_generate")
537
+ output, ok = _run_codex(
538
+ task=task, context=gen_context,
539
+ model=model, sandbox="workspace-write",
540
+ reasoning="high", timeout=timeout,
541
+ cwd=str(svc.project_path),
542
+ )
543
+
544
+ parts = [f"--- Test generation for {len(files)} file(s) ---"]
545
+ parts.append(f"Files: {file_list}")
546
+ parts.append("Sandbox: workspace-write")
547
+ parts.append(f"Model: {model}")
548
+ if ok:
549
+ parts.append(f"\n--- Codex output ---\n{output}")
550
+ else:
551
+ parts.append(f"\n--- Error ---\n{output}")
552
+
553
+ return "\n".join(parts)
554
+
555
+
556
+ def _wf_gemini_review(scope, context, svc, steps_log):
557
+ """Review git changes using Gemini CLI."""
558
+ import subprocess
559
+
560
+ _log_progress(svc, "[1/3] Running git diff...")
561
+ diff_cmd = ["git", "diff"]
562
+ if scope == "staged":
563
+ diff_cmd.append("--cached")
564
+ elif scope == "HEAD":
565
+ diff_cmd.extend(["HEAD~1", "HEAD"])
566
+
567
+ try:
568
+ r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
569
+ cwd=svc.project_path)
570
+ diff_text = r.stdout.strip()
571
+ except Exception as e:
572
+ return f"[step:git_diff] Failed: {e}"
573
+
574
+ if not diff_text:
575
+ return "[step:git_diff] No changes found"
576
+ steps_log.append(f"diff({len(diff_text)}ch)")
577
+
578
+ changed_files = set()
579
+ for line in diff_text.split("\n"):
580
+ if line.startswith("diff --git"):
581
+ parts = line.split(" b/")
582
+ if len(parts) >= 2:
583
+ changed_files.add(parts[-1])
584
+
585
+ _log_progress(svc, f"[2/3] Compressing {len(changed_files)} changed files...")
586
+ with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
587
+ maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
588
+
589
+ review_context = f"Git diff:\n{diff_text[:6000]}\n\nStructural maps:\n{maps}"
590
+ if context:
591
+ review_context += f"\n\nUser context: {context}"
592
+
593
+ _log_progress(svc, "[3/3] Delegating review to Gemini...")
594
+ with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
595
+ delegate_result = _try_delegate(svc, "review", "Review these changes for issues, regressions, and missing tests",
596
+ review_context, steps_log, prefer_gemini=True)
597
+
598
+ parts = [f"--- Changed files ({len(changed_files)}) ---"]
599
+ for f in sorted(changed_files):
600
+ parts.append(f" {f}")
601
+ parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
602
+ if count_tokens(diff_text) > 800:
603
+ parts.append(diff_text[:2000] + "\n... [truncated]")
604
+ else:
605
+ parts.append(diff_text)
606
+ if maps:
607
+ parts.append(f"\n--- Structural maps ---\n{maps}")
608
+ if delegate_result:
609
+ parts.append(f"\n--- Gemini Review ---\n{delegate_result}")
610
+ else:
611
+ reason = _delegate_failure_reason(steps_log)
612
+ parts.append(f"\n--- Gemini Review skipped: {reason or 'no backend available'} ---")
613
+
614
+ return "\n".join(parts)
615
+
616
+
617
+ def _wf_tri_consensus(scope, context, svc, steps_log):
618
+ """Three-way consensus: Ollama + Codex + Gemini in parallel, merge findings."""
619
+ import subprocess
620
+
621
+ _log_progress(svc, "[1/4] Running git diff...")
622
+ diff_cmd = ["git", "diff"]
623
+ if scope == "staged":
624
+ diff_cmd.append("--cached")
625
+ elif scope == "HEAD":
626
+ diff_cmd.extend(["HEAD~1", "HEAD"])
627
+
628
+ try:
629
+ r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
630
+ cwd=svc.project_path)
631
+ diff_text = r.stdout.strip()
632
+ except Exception as e:
633
+ return f"[step:git_diff] Failed: {e}"
634
+
635
+ if not diff_text:
636
+ return "[step:git_diff] No changes found"
637
+ steps_log.append(f"diff({len(diff_text)}ch)")
638
+
639
+ changed_files = set()
640
+ for line in diff_text.split("\n"):
641
+ if line.startswith("diff --git"):
642
+ parts = line.split(" b/")
643
+ if len(parts) >= 2:
644
+ changed_files.add(parts[-1])
645
+ _log_progress(svc, f"[2/4] Compressing {len(changed_files)} changed files...")
646
+ with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
647
+ maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
648
+
649
+ review_context = f"Git diff:\n{diff_text[:4000]}\n\nStructural maps:\n{maps}"
650
+ if context:
651
+ review_context += f"\n\nUser context: {context}"
652
+ review_task = "Review these changes for bugs, regressions, and missing tests. Be concise."
653
+
654
+ # Run cloud backends in parallel (Codex + Gemini)
655
+ _log_progress(svc, "[3/4] Delegating to Codex + Gemini in parallel...")
656
+ codex_steps = []
657
+ gemini_steps = []
658
+
659
+ def _run_codex():
660
+ return _try_delegate(svc, "review", review_task, review_context, codex_steps, prefer_codex=True)
661
+
662
+ def _run_gemini():
663
+ return _try_delegate(svc, "review", review_task, review_context, gemini_steps, prefer_gemini=True)
664
+
665
+ codex_result = gemini_result = ""
666
+ with ThreadPoolExecutor(max_workers=2) as pool:
667
+ fut_codex = pool.submit(_run_codex)
668
+ fut_gemini = pool.submit(_run_gemini)
669
+ try:
670
+ codex_result = fut_codex.result(timeout=100)
671
+ steps_log.extend(codex_steps)
672
+ except Exception:
673
+ codex_result = "[timeout/error]"
674
+ try:
675
+ gemini_result = fut_gemini.result(timeout=100)
676
+ steps_log.extend(gemini_steps)
677
+ except Exception:
678
+ gemini_result = "[timeout/error]"
679
+
680
+ # Merge findings
681
+ parts = [f"--- Changed files ({len(changed_files)}) ---"]
682
+ for f in sorted(changed_files):
683
+ parts.append(f" {f}")
684
+ parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
685
+ if count_tokens(diff_text) > 800:
686
+ parts.append(diff_text[:2000] + "\n... [truncated]")
687
+ else:
688
+ parts.append(diff_text)
689
+ if maps:
690
+ parts.append(f"\n--- Structural maps ---\n{maps}")
691
+
692
+ parts.append("\n" + "=" * 60)
693
+ parts.append("CONSENSUS REVIEW -- Cloud backend analyses")
694
+ parts.append("=" * 60)
695
+
696
+ backends = [
697
+ ("Codex (cloud/GPT)", codex_result),
698
+ ("Gemini (cloud/Google)", gemini_result),
699
+ ]
700
+ available_count = 0
701
+ for label, result in backends:
702
+ if result and result != "[timeout/error]":
703
+ parts.append(f"\n--- {label} ---\n{result}")
704
+ available_count += 1
705
+ else:
706
+ parts.append(f"\n--- {label} ---\n[unavailable or no response]")
707
+
708
+ _log_progress(svc, "[4/4] Merging consensus results...")
709
+ parts.append(f"\n--- Consensus ({available_count}/2 backends responded) ---")
710
+ if available_count == 2:
711
+ parts.append("Both models provided reviews. Compare findings above for agreement/divergence.")
712
+ elif available_count == 1:
713
+ parts.append("Only one backend responded. Enable the other for true consensus.")
714
+ else:
715
+ reasons = [_delegate_failure_reason(codex_steps), _delegate_failure_reason(gemini_steps)]
716
+ detail = ", ".join(r for r in reasons if r) or "check delegate configuration"
717
+ parts.append(f"No backends available: {detail}")
718
+
719
+ return "\n".join(parts)
720
+
721
+
722
+ def _wf_prepare_context(scope, context, svc, steps_log):
723
+ """Build compressed context package for files matching scope."""
724
+ # scope can be: comma-separated file paths, or a search query
725
+ files = []
726
+ if "," in scope or scope.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java")):
727
+ # Treat as file paths
728
+ files = [f.strip() for f in scope.split(",") if f.strip()]
729
+ steps_log.append(f"resolve({len(files)} explicit)")
730
+ else:
731
+ # Treat as search query
732
+ results = svc.indexer.search(scope, top_k=5, include_content=False)
733
+ files = list(dict.fromkeys(r["file"] for r in results))[:5]
734
+ steps_log.append(f"search({len(files)} hits)")
735
+
736
+ if not files:
737
+ return "[step:resolve] No files found"
738
+
739
+ maps = _parallel_compress(files, svc, steps_log)
740
+ return f"--- Context package ({len(files)} files) ---\n{maps}"
741
+
742
+
743
+ def _wf_investigate(scope, context, svc, steps_log):
744
+ """Investigate an error: search → compress → activity → diagnose."""
745
+ query = scope or context or "error"
746
+
747
+ # Step 1: Search for relevant code
748
+ _log_progress(svc, f"[1/4] Searching for '{query[:40]}'...")
749
+ results = svc.indexer.search(query, top_k=5, include_content=True, max_tokens=600)
750
+ steps_log.append(f"search({len(results)}r)")
751
+
752
+ if not results:
753
+ return f"[step:search] No results for '{query}'"
754
+
755
+ # Step 2: Compress top hit files in parallel
756
+ hit_files = list(dict.fromkeys(r["file"] for r in results))[:3]
757
+ _log_progress(svc, f"[2/4] Compressing {len(hit_files)} files...")
758
+ with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
759
+ maps = _parallel_compress(hit_files, svc, steps_log)
760
+
761
+ # Step 3: Recent activity
762
+ activity = ""
763
+ try:
764
+ recent = svc.activity_log.get_recent(limit=10)
765
+ if recent:
766
+ activity = "\n".join(
767
+ f"[{e.get('timestamp', '').split('T')[-1][:8]}] {e.get('tool', '')} → {e.get('summary', '')[:60]}"
768
+ for e in reversed(recent)
769
+ )
770
+ steps_log.append(f"activity({len(recent)})")
771
+ except Exception:
772
+ pass
773
+
774
+ # Step 4: Delegate diagnosis if available
775
+ diag_context = f"Query: {query}\n\nSearch results:\n"
776
+ for r in results[:3]:
777
+ diag_context += f" {r['file']}:L{r['lines']} ({r['type']})\n"
778
+ if maps:
779
+ diag_context += f"\nMaps:\n{maps}"
780
+ if activity:
781
+ diag_context += f"\nRecent activity:\n{activity}"
782
+ if context:
783
+ diag_context += f"\nUser context: {context}"
784
+
785
+ _log_progress(svc, "[4/4] Delegating diagnosis to Gemini...")
786
+ with _progress_ticker(svc, "[4/4] Waiting for Gemini", interval=3.0):
787
+ delegate_result = _try_delegate(svc, "diagnose", f"Investigate: {query}",
788
+ diag_context, steps_log, prefer_gemini=True)
789
+
790
+ parts = [f"--- Search hits for '{query}' ---"]
791
+ for r in results[:5]:
792
+ name = f" {r['name']}" if r.get('name') else ""
793
+ parts.append(f" {r['file']}:L{r['lines']}{name} ({r['type']}, s={r.get('score', 0):.3f})")
794
+ if r.get("content"):
795
+ # Show first few lines of content
796
+ content_lines = r["content"].split("\n")[:5]
797
+ for cl in content_lines:
798
+ parts.append(f" {cl}")
799
+ if maps:
800
+ parts.append(f"\n--- Structural maps ---\n{maps}")
801
+ if activity:
802
+ parts.append(f"\n--- Recent activity ---\n{activity}")
803
+ if delegate_result:
804
+ parts.append(f"\n--- AI Diagnosis ---\n{delegate_result}")
805
+
806
+ return "\n".join(parts)
807
+
808
+
809
+ def _wf_preflight(scope, context, svc, steps_log):
810
+ """Pre-edit check: validate + compress (parallel) + recent activity."""
811
+ files = [f.strip() for f in scope.split(",") if f.strip()]
812
+ if not files:
813
+ return "[step:resolve] No files specified"
814
+
815
+ # Step 1: Validate AND compress all files concurrently
816
+ val_results, maps = _parallel_validate_and_compress(files, svc, steps_log)
817
+
818
+ # Step 2: Recent activity summary
819
+ activity = ""
820
+ try:
821
+ recent = svc.activity_log.get_recent(limit=5)
822
+ if recent:
823
+ activity = "\n".join(
824
+ f"[{e.get('timestamp', '').split('T')[-1][:8]}] {e.get('tool', '')} → {e.get('summary', '')[:60]}"
825
+ for e in reversed(recent)
826
+ )
827
+ steps_log.append(f"activity({len(recent)})")
828
+ except Exception:
829
+ pass
830
+
831
+ parts = [f"--- Preflight ({len(files)} files) ---"]
832
+ for fp in files:
833
+ status, detail = val_results.get(fp, ("UNKNOWN", ""))
834
+ marker = "✓" if status == "PASS" else "✗" if status == "FAIL" else "?"
835
+ line = f" {marker} {fp} — {status}"
836
+ if detail:
837
+ line += f": {detail}"
838
+ parts.append(line)
839
+ if maps:
840
+ parts.append(f"\n--- Structural maps ---\n{maps}")
841
+ if activity:
842
+ parts.append(f"\n--- Recent activity ---\n{activity}")
843
+
844
+ return "\n".join(parts)
845
+
846
+
847
+ def _wf_validate_compress(scope, context, svc, steps_log):
848
+ """Validate and compress a file set in one call — both run in parallel."""
849
+ files = [f.strip() for f in scope.split(",") if f.strip()]
850
+ if not files:
851
+ return "[step:resolve] No files specified"
852
+
853
+ val_results, maps = _parallel_validate_and_compress(files, svc, steps_log)
854
+
855
+ parts = [f"--- Validate + Compress ({len(files)} files) ---"]
856
+ for fp in files:
857
+ status, detail = val_results.get(fp, ("UNKNOWN", ""))
858
+ marker = "✓" if status == "PASS" else "✗" if status == "FAIL" else "?"
859
+ line = f" {marker} {fp} — {status}"
860
+ if detail:
861
+ line += f": {detail}"
862
+ parts.append(line)
863
+ if maps:
864
+ parts.append(f"\n--- Structural maps ---\n{maps}")
865
+
866
+ return "\n".join(parts)
867
+
868
+
869
+ # ── Helpers ───────────────────────────────────────────────────────────────────
870
+
871
+ def _parallel_compress(files: list, svc, steps_log: list,
872
+ per_file_timeout: float = 10.0) -> str:
873
+ """Compress multiple files in parallel, return combined maps."""
874
+ if not files:
875
+ return ""
876
+
877
+ maps = {}
878
+
879
+ def compress_one(fp):
880
+ full_path = str(Path(svc.project_path) / fp)
881
+ try:
882
+ res = svc.compressor.compress_file(full_path, "map")
883
+ if isinstance(res, dict) and res.get("compressed"):
884
+ return fp, res["compressed"]
885
+ except Exception:
886
+ pass
887
+ return fp, None
888
+
889
+ with ThreadPoolExecutor(max_workers=min(len(files), 4)) as pool:
890
+ futures = {pool.submit(compress_one, f): f for f in files}
891
+ for fut in as_completed(futures, timeout=per_file_timeout * len(files)):
892
+ try:
893
+ fp, compressed = fut.result(timeout=per_file_timeout)
894
+ except (FuturesTimeout, Exception):
895
+ continue
896
+ if compressed:
897
+ maps[fp] = compressed
898
+
899
+ steps_log.append(f"compress({len(maps)}/{len(files)})")
900
+
901
+ parts = []
902
+ for fp in files:
903
+ if fp in maps:
904
+ parts.append(f"## {fp}\n{maps[fp]}")
905
+ return "\n\n".join(parts)
906
+
907
+
908
+ def _parallel_validate_and_compress(files: list, svc, steps_log: list):
909
+ """Run validation and compression for all files concurrently.
910
+ Returns (val_results_dict, maps_str)."""
911
+ from services.parser import check_syntax_native_with_timeout
912
+ hybrid_cfg = svc.hybrid_config or {}
913
+ timeout_s = max(1, int(hybrid_cfg.get("validate_timeout_seconds", 35) or 35))
914
+
915
+ val_results = {}
916
+ compress_results = {}
917
+
918
+ def validate_one(fp):
919
+ full = Path(svc.project_path) / fp
920
+ if not full.exists():
921
+ return "validate", fp, "NOT_FOUND", ""
922
+ ext = full.suffix.lower()
923
+ try:
924
+ content = full.read_text(encoding="utf-8", errors="replace")
925
+ result = check_syntax_native_with_timeout(content, ext, timeout_s)
926
+ status = result.get("status", "checker_failed")
927
+ errors = result.get("errors", [])
928
+ if status == "clean":
929
+ return "validate", fp, "PASS", ""
930
+ elif status == "syntax_error":
931
+ err_lines = "; ".join(f"L{e['line']}: {e['text']}" for e in errors[:3])
932
+ return "validate", fp, "FAIL", err_lines
933
+ else:
934
+ return "validate", fp, status.upper(), result.get("detail", "")
935
+ except Exception as e:
936
+ return "validate", fp, "ERROR", str(e)
937
+
938
+ def compress_one(fp):
939
+ full_path = str(Path(svc.project_path) / fp)
940
+ try:
941
+ res = svc.compressor.compress_file(full_path, "map")
942
+ if isinstance(res, dict) and res.get("compressed"):
943
+ return "compress", fp, res["compressed"]
944
+ except Exception:
945
+ pass
946
+ return "compress", fp, None
947
+
948
+ # Submit ALL tasks (validate + compress) into one pool
949
+ with ThreadPoolExecutor(max_workers=min(len(files) * 2, 8)) as pool:
950
+ futures = []
951
+ for fp in files:
952
+ futures.append(pool.submit(validate_one, fp))
953
+ futures.append(pool.submit(compress_one, fp))
954
+
955
+ for fut in as_completed(futures):
956
+ result = fut.result()
957
+ if result[0] == "validate":
958
+ _, fp, status, detail = result
959
+ val_results[fp] = (status, detail)
960
+ else:
961
+ _, fp, compressed = result
962
+ if compressed:
963
+ compress_results[fp] = compressed
964
+
965
+ steps_log.append(f"validate+compress({len(files)}files,{len(compress_results)}maps)")
966
+
967
+ map_parts = []
968
+ for fp in files:
969
+ if fp in compress_results:
970
+ map_parts.append(f"## {fp}\n{compress_results[fp]}")
971
+ maps_str = "\n\n".join(map_parts)
972
+
973
+ return val_results, maps_str
974
+
975
+
976
+ def _try_delegate(svc, task_type: str, task: str, context: str, steps_log: list,
977
+ prefer_codex: bool = False, prefer_gemini: bool = False) -> str:
978
+ """Try to delegate to a specific backend. Returns empty string if unavailable.
979
+
980
+ When prefer_codex or prefer_gemini is set, ONLY that backend is tried —
981
+ no fallback to Ollama. This prevents parallel consensus workflows from
982
+ running duplicate Ollama calls when a preferred backend is unavailable.
983
+ """
984
+ try:
985
+ dcfg = svc.delegate_config or {}
986
+ if not dcfg.get("enabled", True):
987
+ steps_log.append("delegate(skipped:disabled)")
988
+ return ""
989
+
990
+ # --- Codex-only path (no fallthrough) ------------------------------
991
+ if prefer_codex:
992
+ if not dcfg.get("codex_enabled", False):
993
+ steps_log.append("codex(skipped:disabled)")
994
+ return ""
995
+ import cli.tools.delegate as _dm
996
+ from cli.tools.delegate import CODEX_MODELS, _is_codex_on_path, _run_codex
997
+ if not _is_codex_on_path():
998
+ steps_log.append("codex(skipped:not_on_path)")
999
+ return ""
1000
+ # Preflight: cached health check
1001
+ if _dm._codex_available is None:
1002
+ from cli.tools.delegate import check_codex
1003
+ check_codex()
1004
+ if _dm._codex_available is False:
1005
+ steps_log.append("codex(skipped:health_check_failed)")
1006
+ return ""
1007
+ cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
1008
+ model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
1009
+ sandbox = dcfg.get("codex_default_sandbox") or cdef.get("sandbox", "read-only")
1010
+ reasoning = dcfg.get("codex_reasoning_effort") or cdef.get("reasoning", "high")
1011
+ timeout = int(dcfg.get("codex_timeout", 90))
1012
+
1013
+ max_ctx = max(200, int(dcfg.get("codex_max_context_tokens", 4000) or 4000))
1014
+ ctx_text = context[:max_ctx * 4] if count_tokens(context) > max_ctx else context
1015
+
1016
+ _log_progress(svc, f"[delegate] Running Codex ({model}, {sandbox})...")
1017
+ output, ok = _run_codex(
1018
+ task=task, context=ctx_text,
1019
+ model=model, sandbox=sandbox,
1020
+ reasoning=reasoning, timeout=timeout,
1021
+ cwd=str(svc.project_path),
1022
+ )
1023
+ if ok and output:
1024
+ _log_progress(svc, f"[delegate] Codex done ({task_type})")
1025
+ steps_log.append(f"codex({task_type},{model})")
1026
+ return output
1027
+ # Log the specific failure from output
1028
+ if output and output.startswith("["):
1029
+ steps_log.append(f"codex({output.split(']')[0]}])".replace("[", ""))
1030
+ else:
1031
+ steps_log.append("codex(error:no_output)")
1032
+ return ""
1033
+
1034
+ # --- Gemini-only path (no fallthrough) -----------------------------
1035
+ if prefer_gemini:
1036
+ if not dcfg.get("gemini_enabled", False):
1037
+ steps_log.append("gemini(skipped:disabled)")
1038
+ return ""
1039
+ import cli.tools.delegate as _dm
1040
+ from cli.tools.delegate import GEMINI_MODELS, _is_gemini_on_path, _run_gemini
1041
+ if not _is_gemini_on_path():
1042
+ steps_log.append("gemini(skipped:not_on_path)")
1043
+ return ""
1044
+ # Preflight: cached health check (gemini --version, 5s timeout)
1045
+ if _dm._gemini_available is None:
1046
+ from cli.tools.delegate import check_gemini
1047
+ check_gemini()
1048
+ if _dm._gemini_available is False:
1049
+ steps_log.append("gemini(skipped:health_check_failed)")
1050
+ return ""
1051
+ gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
1052
+ model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
1053
+ timeout = int(dcfg.get("gemini_timeout", 45))
1054
+
1055
+ max_ctx = max(200, int(dcfg.get("gemini_max_context_tokens", 8000) or 8000))
1056
+ ctx_text = context[:max_ctx * 4] if count_tokens(context) > max_ctx else context
1057
+
1058
+ _log_progress(svc, f"[delegate] Running Gemini ({model})...")
1059
+ output, ok, _ = _run_gemini(
1060
+ task=task, context=ctx_text,
1061
+ model=model, timeout=timeout,
1062
+ cwd=str(svc.project_path),
1063
+ )
1064
+ if ok and output:
1065
+ steps_log.append(f"gemini({task_type},{model})")
1066
+ return output
1067
+ # Log the specific failure from output
1068
+ if output and output.startswith("["):
1069
+ steps_log.append(f"gemini({output.split(']')[0]}])".replace("[", ""))
1070
+ else:
1071
+ steps_log.append("gemini(error:no_output)")
1072
+ return ""
1073
+
1074
+ # --- Ollama path (default) -----------------------------------------
1075
+ # For heavy tasks, prefer a cloud CLI if one is available on the system.
1076
+ # Ollama can take 30-90s per call; cloud CLIs are faster for reviews/diagnose.
1077
+ _LIGHT_TASKS = {"ask", "explain", "summarize", "docstring"}
1078
+ if task_type not in _LIGHT_TASKS:
1079
+ import cli.tools.delegate as _dm_auto
1080
+ from cli.tools.delegate import (
1081
+ CODEX_MODELS,
1082
+ GEMINI_MODELS,
1083
+ _is_codex_on_path,
1084
+ _is_gemini_on_path,
1085
+ _run_codex,
1086
+ _run_gemini,
1087
+ )
1088
+ # Gemini first (prefer; enabled in config for this project)
1089
+ _gem_ok = (_dm_auto._gemini_available is True) or (
1090
+ _dm_auto._gemini_available is None and _is_gemini_on_path()
1091
+ )
1092
+ if _gem_ok and _dm_auto._gemini_available is not False:
1093
+ gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
1094
+ g_model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
1095
+ g_timeout = int(dcfg.get("gemini_timeout", 45))
1096
+ g_max = max(200, int(dcfg.get("gemini_max_context_tokens", 8000) or 8000))
1097
+ g_ctx = context[:g_max * 4] if count_tokens(context) > g_max else context
1098
+ _log_progress(svc, f"[auto] Routing {task_type} → Gemini (skipping slow Ollama)...")
1099
+ g_out, g_ok, _ = _run_gemini(
1100
+ task=task, context=g_ctx, model=g_model,
1101
+ timeout=g_timeout, cwd=str(svc.project_path),
1102
+ )
1103
+ if g_ok and g_out:
1104
+ steps_log.append(f"gemini({task_type},{g_model},auto)")
1105
+ return g_out
1106
+ # Codex second
1107
+ _codex_ok = (_dm_auto._codex_available is True) or (
1108
+ _dm_auto._codex_available is None and _is_codex_on_path()
1109
+ )
1110
+ if _codex_ok and _dm_auto._codex_available is not False:
1111
+ cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
1112
+ c_model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
1113
+ c_sandbox = dcfg.get("codex_default_sandbox") or cdef.get("sandbox", "read-only")
1114
+ c_reason = dcfg.get("codex_reasoning_effort") or cdef.get("reasoning", "high")
1115
+ c_timeout = int(dcfg.get("codex_timeout", 90))
1116
+ c_max = max(200, int(dcfg.get("codex_max_context_tokens", 4000) or 4000))
1117
+ c_ctx = context[:c_max * 4] if count_tokens(context) > c_max else context
1118
+ _log_progress(svc, f"[auto] Routing {task_type} → Codex (skipping slow Ollama)...")
1119
+ c_out, c_ok = _run_codex(
1120
+ task=task, context=c_ctx, model=c_model,
1121
+ sandbox=c_sandbox, reasoning=c_reason,
1122
+ timeout=c_timeout, cwd=str(svc.project_path),
1123
+ )
1124
+ if c_ok and c_out:
1125
+ steps_log.append(f"codex({task_type},{c_model},auto)")
1126
+ return c_out
1127
+
1128
+ ollama = svc.ollama_client
1129
+ if not ollama or not ollama.is_available():
1130
+ return ""
1131
+
1132
+ from cli.tools.delegate import DELEGATE_TASKS, _fallback_model_order, resolve_model_name
1133
+
1134
+ tdef = DELEGATE_TASKS.get(task_type)
1135
+ if not tdef:
1136
+ return ""
1137
+
1138
+ req_model = dcfg.get(f"{task_type}_model") or dcfg.get("preferred_model") or tdef["default_model"]
1139
+ avail = ollama.list_models() or []
1140
+ model = resolve_model_name(req_model, avail)
1141
+ if not model:
1142
+ for cand in _fallback_model_order(task_type) + avail:
1143
+ model = resolve_model_name(cand, avail)
1144
+ if model:
1145
+ break
1146
+ if not model:
1147
+ return ""
1148
+
1149
+ # Truncate context
1150
+ max_ctx = max(200, int(dcfg.get("max_context_tokens", 1400) or 1400))
1151
+ if count_tokens(context) > max_ctx:
1152
+ context = context[:max_ctx * 4]
1153
+
1154
+ response = ollama.generate(
1155
+ model=model,
1156
+ prompt=f"{task}\n\nContext:\n{context}",
1157
+ system=tdef.get("system_prompt", ""),
1158
+ max_tokens=int(dcfg.get("max_tokens", 512)),
1159
+ temperature=float(dcfg.get("temperature", 0.3)),
1160
+ )
1161
+ steps_log.append(f"delegate({task_type},{model})")
1162
+ return response.strip() if response else ""
1163
+ except Exception as e:
1164
+ steps_log.append(f"delegate(error:{type(e).__name__})")
1165
+ return ""