differix 0.1.0a1__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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
main.py ADDED
@@ -0,0 +1,2796 @@
1
+ """
2
+ VegeX orchestrator entry point.
3
+
4
+ State machine: research → edit → review → (research | done).
5
+
6
+ Every write to a plan step's target file marks that step completed
7
+ immediately; the reviewer's verdict decides whether to keep the write or
8
+ revert it. Coherence across files runs only when no plan step remains
9
+ pending — mid-plan, a missing file is expected, not a failure.
10
+
11
+ Files created by the pipeline (as opposed to files that existed before
12
+ the pipeline first touched them) are tracked in `pipeline_created_files`.
13
+ On rejection, a pipeline-created file is deleted rather than restored —
14
+ this prevents the accumulation of partial writes across cycles.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import select
20
+ import sys
21
+ import re
22
+ import time
23
+ from datetime import datetime
24
+ from typing import Union
25
+ from dotenv import load_dotenv
26
+
27
+ from tools.security.behavior_guard import BehaviorGuard
28
+ from rich.console import Console
29
+ from rich.panel import Panel
30
+ from rich.table import Table
31
+
32
+ console = Console()
33
+
34
+ import config
35
+
36
+ from tools.file_manager import FolderSandbox
37
+ from tools.compiler import run_universal_build
38
+ from tools.backup_manager import (
39
+ get_repo,
40
+ make_auto_commit,
41
+ safe_rollback,
42
+ show_all_commits,
43
+ create_success_tag,
44
+ )
45
+ from tools.visual_reviewer import review_page as visual_review_page
46
+ from tools.agents_chat import handle_msg_command
47
+ from tools.changelog_engine import ChangelogEngine
48
+ from tools.project_structure import generate_or_update_structure
49
+ from tools.security_filter import find_dangerous_code
50
+ from tools.reasoning_logger import log_reasoning
51
+ from tools.project_scanner import get_full_project_context as get_project_context
52
+ from tools.drift_guard import DriftGuard
53
+ from tools.coherence_checker import check_coherence, render_report as render_coherence_report
54
+ from tools.project_context_gatherer import (
55
+ gather_related_context,
56
+ render_related_context,
57
+ )
58
+ from tools.cost_predictor import predict_cost
59
+ from tools.plugin_manager import PluginManager
60
+
61
+ from agents.researcher import run_researcher
62
+ from agents.editor import run_editor
63
+ from agents.reviewer import run_reviewer
64
+
65
+ try:
66
+ from memory.manager import MemoryManager
67
+ except ImportError:
68
+ MemoryManager = None
69
+
70
+ from tools.lesson_manager import (
71
+ LESSONS_DIRS,
72
+ move_to_candidates,
73
+ move_to_promoted,
74
+ move_to_deprecated,
75
+ load_promoted_lessons,
76
+ analyze_lesson_outcome,
77
+ record_lesson_trigger,
78
+ record_lesson_helpful,
79
+ record_lesson_violation,
80
+ lesson_memory_stats,
81
+ )
82
+ from tools.atomic_changeset import ChangeSet
83
+ from tools.contract_validator import ContractValidator
84
+ from tools.session_adr import SessionADR, build_entry as build_adr_entry
85
+ from tools.handshake import build_session_context
86
+
87
+ from cli.model_cli import handle_llm_command, sync_active_models_specs
88
+
89
+ from tools.state_manager import (
90
+ AppState,
91
+ apply_researcher_plan,
92
+ build_state_template,
93
+ load_state,
94
+ save_state,
95
+ )
96
+
97
+ load_dotenv()
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Framework paths
102
+ # ---------------------------------------------------------------------------
103
+ _FRAMEWORK_ROOT = os.path.abspath(os.path.dirname(__file__))
104
+ _VEGEX_DIR = os.path.join(_FRAMEWORK_ROOT, ".vegex")
105
+
106
+ REGISTRY_FILE = os.path.join(_VEGEX_DIR, "projects_registry.json")
107
+ LEGACY_REGISTRY_FILE = os.path.join(os.getcwd(), "projects_registry.json")
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # stdin handling
112
+ # ---------------------------------------------------------------------------
113
+ def _drain_stdin() -> None:
114
+ try:
115
+ if not sys.stdin.isatty():
116
+ return
117
+ while select.select([sys.stdin], [], [], 0.0)[0]:
118
+ sys.stdin.readline()
119
+ except Exception:
120
+ pass
121
+
122
+
123
+ _PASTE_ARTIFACT_PREFIXES = ("#", "-", ">", "|", "```", " ", " ")
124
+
125
+
126
+ def _looks_like_paste_artifact(line: str) -> bool:
127
+ stripped = line.strip()
128
+ if not stripped:
129
+ return False
130
+ return stripped.startswith(_PASTE_ARTIFACT_PREFIXES)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Registry
135
+ # ---------------------------------------------------------------------------
136
+ def load_registry() -> dict:
137
+ if os.path.exists(REGISTRY_FILE):
138
+ try:
139
+ with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
140
+ data = json.load(f)
141
+ if isinstance(data, dict) and data:
142
+ return data
143
+ except Exception:
144
+ pass
145
+
146
+ if (
147
+ os.path.exists(LEGACY_REGISTRY_FILE)
148
+ and os.path.abspath(LEGACY_REGISTRY_FILE) != os.path.abspath(REGISTRY_FILE)
149
+ ):
150
+ try:
151
+ with open(LEGACY_REGISTRY_FILE, "r", encoding="utf-8") as f:
152
+ legacy = json.load(f)
153
+ if isinstance(legacy, dict) and legacy:
154
+ _write_registry(legacy)
155
+ print(
156
+ f"[Registry] Migrated from legacy {LEGACY_REGISTRY_FILE} "
157
+ f"to {REGISTRY_FILE}"
158
+ )
159
+ return legacy
160
+ except Exception:
161
+ pass
162
+
163
+ cwd = os.getcwd()
164
+ return {
165
+ "1": {
166
+ "name": os.path.basename(cwd) or "Current Workspace",
167
+ "path": cwd,
168
+ "lang": ".py",
169
+ }
170
+ }
171
+
172
+
173
+ def save_registry(registry: dict) -> None:
174
+ _write_registry(registry)
175
+
176
+
177
+ def _write_registry(registry: dict) -> None:
178
+ os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
179
+ tmp = REGISTRY_FILE + ".tmp"
180
+ try:
181
+ with open(tmp, "w", encoding="utf-8") as f:
182
+ json.dump(registry, f, indent=2, ensure_ascii=False)
183
+ os.replace(tmp, REGISTRY_FILE)
184
+ except Exception:
185
+ try:
186
+ os.unlink(tmp)
187
+ except OSError:
188
+ pass
189
+ raise
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Text helpers
194
+ # ---------------------------------------------------------------------------
195
+ def extract_json_payload(raw_text: str) -> Union[dict, list]:
196
+ if not isinstance(raw_text, str):
197
+ return raw_text
198
+
199
+ match = re.search(
200
+ r"```(?:json)?\s*([\[\{].*?[\]\}])\s*```", raw_text, re.DOTALL
201
+ )
202
+ candidate = match.group(1) if match else raw_text.strip()
203
+
204
+ start_obj = candidate.find("{")
205
+ start_arr = candidate.find("[")
206
+
207
+ if start_arr != -1 and (start_obj == -1 or start_arr < start_obj):
208
+ end_arr = candidate.rfind("]")
209
+ if end_arr != -1:
210
+ candidate = candidate[start_arr: end_arr + 1]
211
+ elif start_obj != -1:
212
+ end_obj = candidate.rfind("}")
213
+ if end_obj != -1:
214
+ candidate = candidate[start_obj: end_obj + 1]
215
+
216
+ return json.loads(candidate)
217
+
218
+
219
+ def sanitize_generated_code(code_str: str, allow_patches: bool = False) -> str:
220
+ if not isinstance(code_str, str):
221
+ raise ValueError(
222
+ "Code input must be a string, got: " + type(code_str).__name__
223
+ )
224
+
225
+ fence_match = re.search(
226
+ r"```(?:\w+)?\n(.*?)```", code_str.strip(), re.DOTALL
227
+ )
228
+ if fence_match:
229
+ code_str = fence_match.group(1)
230
+
231
+ cleaned_lines = []
232
+ for line in code_str.splitlines():
233
+ stripped = line.strip()
234
+
235
+ if re.match(r"^(<{4,}|={4,}|>{4,})", stripped):
236
+ continue
237
+
238
+ if stripped.startswith("```") and not allow_patches:
239
+ continue
240
+
241
+ cleaned_lines.append(line)
242
+
243
+ result = "\n".join(cleaned_lines).strip()
244
+
245
+ if not result:
246
+ raise ValueError(
247
+ "Code payload is empty after sanitization. "
248
+ "The LLM response may have contained only conflict markers or code fences."
249
+ )
250
+
251
+ return result
252
+
253
+
254
+ def validate_required_state_fields(state: dict) -> tuple:
255
+ if not isinstance(state, dict):
256
+ return False, ["state (must be dict)"]
257
+
258
+ if not state.get("task_prompt") and state.get("goal"):
259
+ state["task_prompt"] = state["goal"]
260
+ elif not state.get("goal") and state.get("task_prompt"):
261
+ state["goal"] = state["task_prompt"]
262
+
263
+ required_fields = ["task_prompt", "current_step", "iteration_count"]
264
+ missing = [f for f in required_fields if f not in state or state[f] is None]
265
+
266
+ if "task_prompt" in state and not str(state.get("task_prompt", "")).strip():
267
+ if "task_prompt (cannot be empty)" not in missing:
268
+ missing.append("task_prompt (cannot be empty)")
269
+
270
+ return len(missing) == 0, missing
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Orchestrator
275
+ # ---------------------------------------------------------------------------
276
+ class OrchestratorConsole:
277
+ VEGEX_FRAMEWORK_ROOT = _FRAMEWORK_ROOT
278
+
279
+ def __init__(self):
280
+ self.total_cost = 0.0
281
+ self.should_pause = False
282
+ self.should_stop = False
283
+ self.registry = load_registry()
284
+
285
+ self.proj = self.registry.get("1")
286
+ if self.proj and os.path.exists(self.proj["path"]):
287
+ self.sandbox = FolderSandbox(self.proj["path"])
288
+ self.logger = ChangelogEngine(project_root=self.proj["path"])
289
+ self.memory = (
290
+ MemoryManager(db_path=os.path.join(self.proj["path"], "memory.db"))
291
+ if MemoryManager
292
+ else None
293
+ )
294
+ self.adr = SessionADR(self.proj["path"])
295
+ self.validator = ContractValidator(self.proj["path"])
296
+ self._guard = DriftGuard(self.proj["path"])
297
+ self.plugin_mgr = PluginManager(self.proj["path"])
298
+ else:
299
+ self.sandbox = None
300
+ self.logger = None
301
+ self.memory = MemoryManager() if MemoryManager else None
302
+ self.adr = None
303
+ self.validator = None
304
+ self._guard = None
305
+ self.plugin_mgr = None
306
+
307
+ # ------------------------------------------------------------------ #
308
+ # State I/O
309
+ # ------------------------------------------------------------------ #
310
+ def get_state_path(self, project_dir: str = None) -> str:
311
+ target_dir = project_dir or (self.proj["path"] if self.proj else None)
312
+ if target_dir and os.path.isdir(target_dir):
313
+ return os.path.join(target_dir, "state.json")
314
+ return os.path.join(self.VEGEX_FRAMEWORK_ROOT, "state.json")
315
+
316
+ def load_state(self, project_dir: str = None) -> dict:
317
+ state_file = self.get_state_path(project_dir)
318
+
319
+ if self._guard is not None:
320
+ if not self._guard.check_state("last-save", state_file):
321
+ console.print(
322
+ "[dim]state.json changed externally — reloading from disk.[/dim]"
323
+ )
324
+
325
+ app_state = load_state(state_file)
326
+
327
+ if hasattr(app_state, "model_dump"):
328
+ data = app_state.model_dump()
329
+ elif isinstance(app_state, dict):
330
+ data = app_state
331
+ else:
332
+ data = {}
333
+
334
+ exec_ctrl = data.get("execution_control", {}) or {}
335
+ task_spec = data.get("task_spec", {}) or {}
336
+
337
+ data["current_step"] = exec_ctrl.get("current_step", "init")
338
+ data["status"] = exec_ctrl.get("status", "idle")
339
+ data["active_agent"] = exec_ctrl.get("active_agent", "researcher")
340
+ data["iteration_count"] = exec_ctrl.get("iteration_count", 0)
341
+
342
+ data["task_prompt"] = task_spec.get("raw_user_prompt", "")
343
+ data["goal"] = (
344
+ task_spec.get("raw_user_prompt", "")
345
+ or task_spec.get("decomposed_goal", "")
346
+ )
347
+ return data
348
+
349
+ def save_state(self, state: Union[dict, AppState], project_dir: str = None):
350
+ state_file = self.get_state_path(project_dir)
351
+
352
+ if isinstance(state, AppState):
353
+ state = state.model_dump()
354
+
355
+ if isinstance(state, dict):
356
+ if "execution_control" not in state or not isinstance(
357
+ state["execution_control"], dict
358
+ ):
359
+ state["execution_control"] = {}
360
+ if "task_spec" not in state or not isinstance(
361
+ state["task_spec"], dict
362
+ ):
363
+ state["task_spec"] = {}
364
+
365
+ if "current_step" in state:
366
+ state["execution_control"]["current_step"] = state["current_step"]
367
+ if "status" in state:
368
+ state["execution_control"]["status"] = state["status"]
369
+ if "active_agent" in state:
370
+ state["execution_control"]["active_agent"] = state["active_agent"]
371
+ if "iteration_count" in state:
372
+ state["execution_control"]["iteration_count"] = state["iteration_count"]
373
+
374
+ if "task_prompt" in state:
375
+ state["task_spec"]["raw_user_prompt"] = state["task_prompt"]
376
+ if "goal" in state and not state["task_spec"].get("raw_user_prompt"):
377
+ state["task_spec"]["raw_user_prompt"] = state["goal"]
378
+
379
+ app_state = AppState.model_validate(state)
380
+ else:
381
+ app_state = state
382
+
383
+ save_state(app_state, state_file)
384
+
385
+ if self._guard is not None:
386
+ self._guard.pin_state("last-save", state_file)
387
+
388
+ # ------------------------------------------------------------------ #
389
+ # Plan step helpers
390
+ # ------------------------------------------------------------------ #
391
+ def _mark_step_completed(self, state: dict, target_file: str) -> bool:
392
+ if not target_file:
393
+ return False
394
+ plan = state.get("plan", [])
395
+ if not isinstance(plan, list):
396
+ return False
397
+
398
+ for step in plan:
399
+ if not isinstance(step, dict):
400
+ continue
401
+ if (
402
+ step.get("target_file") == target_file
403
+ and step.get("status") in ("pending", "in_progress")
404
+ ):
405
+ step["status"] = "completed"
406
+ state["plan"] = plan
407
+ return True
408
+ return False
409
+
410
+ def _mark_step_pending(self, state: dict, target_file: str) -> bool:
411
+ if not target_file:
412
+ return False
413
+ plan = state.get("plan", [])
414
+ if not isinstance(plan, list):
415
+ return False
416
+
417
+ for step in plan:
418
+ if not isinstance(step, dict):
419
+ continue
420
+ if (
421
+ step.get("target_file") == target_file
422
+ and step.get("status") == "completed"
423
+ ):
424
+ step["status"] = "pending"
425
+ state["plan"] = plan
426
+ return True
427
+ return False
428
+
429
+ def _coherence_should_run(self, state: dict) -> bool:
430
+ plan = state.get("plan", [])
431
+ if not plan:
432
+ return False
433
+ pending = [
434
+ s for s in plan
435
+ if isinstance(s, dict) and s.get("status") == "pending"
436
+ ]
437
+ return len(pending) == 0
438
+
439
+ def _check_rejection_loop(self, state: dict, reason: str) -> bool:
440
+ """
441
+ Return True if the same rejection reason has appeared at least 3
442
+ times in a row.
443
+ """
444
+ import re as _re
445
+
446
+ def _normalize(text: str) -> str:
447
+ t = (text or "").lower()
448
+ t = _re.sub(r"[^\w\s]", " ", t)
449
+ t = _re.sub(r"\b(contains?|has|have|found|blocking|across|project|files?)\b", " ", t)
450
+ t = _re.sub(r"\s+", " ", t).strip()
451
+ return " ".join(t.split()[:8])
452
+
453
+ key = _normalize(reason)
454
+ history = state.get("recent_rejections", [])
455
+ if not isinstance(history, list):
456
+ history = []
457
+
458
+ history.append(key)
459
+ history = history[-5:]
460
+ state["recent_rejections"] = history
461
+
462
+ if len(history) >= 3 and len(set(history[-3:])) == 1:
463
+ return True
464
+ return False
465
+
466
+ # ------------------------------------------------------------------ #
467
+ # Pipeline-created file tracking
468
+ # ------------------------------------------------------------------ #
469
+ def _track_pipeline_file(self, state: dict, rel_path: str) -> None:
470
+ if not rel_path:
471
+ return
472
+ created = state.get("pipeline_created_files", [])
473
+ if not isinstance(created, list):
474
+ created = []
475
+ if rel_path not in created:
476
+ created.append(rel_path)
477
+ state["pipeline_created_files"] = created
478
+
479
+ def _untrack_pipeline_file(self, state: dict, rel_path: str) -> None:
480
+ created = state.get("pipeline_created_files", [])
481
+ if isinstance(created, list) and rel_path in created:
482
+ created.remove(rel_path)
483
+ state["pipeline_created_files"] = created
484
+
485
+ def _is_pipeline_file(self, state: dict, rel_path: str) -> bool:
486
+ created = state.get("pipeline_created_files", [])
487
+ return isinstance(created, list) and rel_path in created
488
+
489
+ def _revert_file(self, state: dict, rel_path: str, backup: str) -> None:
490
+ if not rel_path:
491
+ return
492
+ abs_path = os.path.join(self.proj["path"], rel_path)
493
+
494
+ if backup:
495
+ self.sandbox.safe_write_code(rel_path, backup)
496
+ self._untrack_pipeline_file(state, rel_path)
497
+ return
498
+
499
+ try:
500
+ if os.path.exists(abs_path):
501
+ os.remove(abs_path)
502
+ console.print(f"[dim]Removed pipeline-created file: {rel_path}[/dim]")
503
+ except OSError as exc:
504
+ console.print(
505
+ f"[dim yellow]Could not remove {rel_path}: {exc}[/dim yellow]"
506
+ )
507
+ self._untrack_pipeline_file(state, rel_path)
508
+
509
+ def _check_critical_files_intact(self) -> list:
510
+ critical_files = (
511
+ "main.py",
512
+ "config.py",
513
+ "tools/state_manager.py",
514
+ "tools/file_manager.py",
515
+ "tools/atomic_changeset.py",
516
+ "tools/security_filter.py",
517
+ "memory/schemas.py",
518
+ "memory/storage.py",
519
+ "agents/researcher.py",
520
+ "agents/editor.py",
521
+ "agents/reviewer.py",
522
+ )
523
+ missing = []
524
+ for rel in critical_files:
525
+ path = os.path.join(self.proj["path"], rel)
526
+ if not os.path.exists(path):
527
+ missing.append(rel)
528
+ return missing
529
+
530
+ # ------------------------------------------------------------------ #
531
+ # Context builders
532
+ # ------------------------------------------------------------------ #
533
+ def _build_session_context(
534
+ self, state: dict, role: str, record_trigger: bool = False
535
+ ) -> str:
536
+ try:
537
+ result = build_session_context(
538
+ project_root=self.proj["path"],
539
+ state=state,
540
+ role=role,
541
+ )
542
+ state["active_lesson_node_ids"] = list(result.lesson_node_ids or [])
543
+ if record_trigger and result.lesson_node_ids:
544
+ try:
545
+ record_lesson_trigger(result.lesson_node_ids)
546
+ except Exception as trig_exc:
547
+ console.print(
548
+ f"[dim yellow]Record trigger failed: {trig_exc}[/dim yellow]"
549
+ )
550
+ return result.text
551
+ except Exception as exc:
552
+ console.print(f"[dim yellow]Handshake build failed: {exc}[/dim yellow]")
553
+ return ""
554
+
555
+ def _build_related_context(self, state: dict, target_file: str) -> str:
556
+ try:
557
+ ctx = gather_related_context(
558
+ project_root=self.proj["path"],
559
+ target_file=target_file,
560
+ plan=state.get("plan", []),
561
+ budget_tokens=getattr(config, "EDITOR_RELATED_BUDGET", 2000),
562
+ )
563
+ rendered = render_related_context(ctx)
564
+ if rendered:
565
+ console.print(
566
+ f"[dim]Related context: {len(ctx.files)} file(s), "
567
+ f"{ctx.total_chars} chars[/dim]"
568
+ )
569
+ return rendered
570
+ except Exception as exc:
571
+ console.print(f"[dim yellow]Related context failed: {exc}[/dim yellow]")
572
+ return ""
573
+
574
+ def _build_coherence_report(self, state: dict) -> str:
575
+ try:
576
+ pipeline_files = state.get("pipeline_created_files") or []
577
+ if not pipeline_files:
578
+ return ""
579
+
580
+ report = check_coherence(self.proj["path"], files=pipeline_files)
581
+ rendered = render_coherence_report(report)
582
+ if report.errors:
583
+ console.print(
584
+ f"[yellow]Coherence: {len(report.errors)} error(s), "
585
+ f"{len(report.warnings)} warning(s) "
586
+ f"(scoped to {len(pipeline_files)} pipeline files)[/yellow]"
587
+ )
588
+ return rendered
589
+ except Exception as exc:
590
+ console.print(f"[dim yellow]Coherence check failed: {exc}[/dim yellow]")
591
+ return ""
592
+
593
+ # ------------------------------------------------------------------ #
594
+ # UI helpers
595
+ # ------------------------------------------------------------------ #
596
+ def print_projects_table(self):
597
+ table = Table(
598
+ title="Remembered Projects",
599
+ show_header=True,
600
+ header_style="bold green",
601
+ )
602
+ table.add_column("Key", justify="center", style="bold yellow")
603
+ table.add_column("Project Name", style="white")
604
+ table.add_column("Path", style="cyan")
605
+ table.add_column("Lang", style="magenta")
606
+
607
+ for k, v in self.registry.items():
608
+ is_active = (
609
+ self.proj
610
+ and os.path.abspath(v["path"]) == os.path.abspath(self.proj["path"])
611
+ )
612
+ active_marker = (
613
+ " [bold green](active)[/bold green]" if is_active else ""
614
+ )
615
+ table.add_row(
616
+ k, v["name"] + active_marker, v["path"], v.get("lang", ".py")
617
+ )
618
+
619
+ console.print(table)
620
+ console.print(f"[dim]Registry: {REGISTRY_FILE}[/dim]")
621
+
622
+ def show_status(self):
623
+ state = self.load_state()
624
+ table = Table(
625
+ title="System Status", show_header=True, header_style="bold cyan"
626
+ )
627
+ table.add_column("Property", style="yellow")
628
+ table.add_column("Value", style="white")
629
+
630
+ table.add_row("Active Project", self.proj["name"] if self.proj else "None")
631
+ table.add_row("Workspace Path", self.proj["path"] if self.proj else "None")
632
+ table.add_row("Current Step", str(state.get("current_step", "N/A")))
633
+ table.add_row("Iteration Count", str(state.get("iteration_count", 0)))
634
+ table.add_row("Session Cost", f"${self.total_cost:.5f}")
635
+
636
+ compiler_log = str(state.get("compiler_output") or "None")
637
+ table.add_row(
638
+ "Last Compiler Output",
639
+ compiler_log[:100] + "..." if len(compiler_log) > 100 else compiler_log,
640
+ )
641
+ console.print(table)
642
+
643
+ def show_changelog(self):
644
+ log_dir = (
645
+ os.path.join(self.proj["path"], "iteration_logs")
646
+ if self.proj
647
+ else os.path.join(self.VEGEX_FRAMEWORK_ROOT, "iteration_logs")
648
+ )
649
+ if not os.path.exists(log_dir):
650
+ console.print("[yellow]No changelogs recorded yet.[/yellow]")
651
+ return
652
+
653
+ logs = sorted(
654
+ [f for f in os.listdir(log_dir) if f.endswith(".json")], reverse=True
655
+ )[:5]
656
+ if not logs:
657
+ console.print("[yellow]No iteration logs found.[/yellow]")
658
+ return
659
+
660
+ console.print(
661
+ Panel("[bold cyan]Recent Iteration Logs[/bold cyan]", border_style="cyan")
662
+ )
663
+ for log_file in logs:
664
+ filepath = os.path.join(log_dir, log_file)
665
+ try:
666
+ with open(filepath, "r", encoding="utf-8") as f:
667
+ entry = json.load(f)
668
+ step_display = entry.get("current_step")
669
+ if step_display is None:
670
+ iterations = entry.get("iterations") or []
671
+ if iterations:
672
+ step_display = iterations[-1].get("step")
673
+ console.print(
674
+ f"[bold yellow]{log_file}[/bold yellow] | "
675
+ f"Step: [green]{step_display or 'N/A'}[/green]"
676
+ )
677
+ except Exception as e:
678
+ console.print(f"[red]Error reading {log_file}: {e}[/red]")
679
+
680
+ def update_costs(self, meta, agent_name: str = None):
681
+ provider = getattr(meta, "provider", "openrouter")
682
+ model_id = getattr(meta, "model_id", None)
683
+
684
+ if provider == "ollama_fallback":
685
+ price_in, price_out = 0.0, 0.0
686
+ elif agent_name:
687
+ price_in, price_out, _ctx = config.get_current_price(agent_name)
688
+ else:
689
+ price_in = getattr(config, "PRICE_INPUT_1M", 0.14)
690
+ price_out = getattr(config, "PRICE_OUTPUT_1M", 0.28)
691
+
692
+ prompt_tokens = getattr(meta, "prompt_token_count", 0) or 0
693
+ completion_tokens = getattr(meta, "candidates_token_count", 0) or 0
694
+
695
+ cost = (prompt_tokens * (price_in / 1_000_000)) + (
696
+ completion_tokens * (price_out / 1_000_000)
697
+ )
698
+ self.total_cost += cost
699
+
700
+ table = Table(show_header=True, header_style="bold blue", box=None)
701
+ table.add_column("Model", justify="left", style="cyan")
702
+ table.add_column("Input Tokens", justify="right")
703
+ table.add_column("Output Tokens", justify="right")
704
+ table.add_column("Step Cost", justify="right", style="green")
705
+ table.add_column("Session Total", justify="right", style="bold green")
706
+
707
+ model_label = model_id or "unknown"
708
+ if provider == "ollama_fallback":
709
+ model_label += " (local fallback)"
710
+
711
+ table.add_row(
712
+ model_label,
713
+ f"{prompt_tokens:,}",
714
+ f"{completion_tokens:,}",
715
+ f"${cost:.5f}",
716
+ f"${self.total_cost:.5f}",
717
+ )
718
+ console.print(table)
719
+
720
+ def print_help(self):
721
+ help_text = """
722
+ [bold cyan]Available Commands:[/bold cyan]
723
+ [yellow]task / t <prompt>[/yellow] - Bootstrap project task & plan from CLI prompt
724
+ [yellow]run / start [prompt][/yellow] - Execute multi-agent iteration pipeline
725
+ [yellow]cd <path|key>[/yellow] - Navigate to path or switch project by key index
726
+ [yellow]projects / ls[/yellow] - Display remembered project registry
727
+ [yellow]llm <cmd>[/yellow] - Model configuration and provider selection
728
+ [yellow]msg[/yellow] - Chat directly with agents for active workspace
729
+ [yellow]handshake [role][/yellow] - Preview the session context for a role (debugging)
730
+ [yellow]coherence[/yellow] - Run cross-file coherence check on active project
731
+ [yellow]status[/yellow] - Display current execution metrics and active step
732
+ [yellow]pause[/yellow] - Pause execution after current step completes
733
+ [yellow]stop[/yellow] - Halt current agent loop
734
+ [yellow]log / changelog[/yellow] - Render session changelog summaries
735
+ [yellow]history[/yellow] - View Git commit log for current project
736
+ [yellow]rollback <sha>[/yellow] - Revert workspace to specific commit SHA
737
+ [yellow]lesson <cmd>[/yellow] - Lesson management (list | candidate | promote | deprecate)
738
+ [yellow]help[/yellow] - Show command directory
739
+ [yellow]reflect[/yellow] - Run reflection manually on recent failures
740
+ [yellow]upd-tiers[/yellow] - Update LLM tiers in llm_selector
741
+ [yellow]exit / quit[/yellow] - Exit orchestrator
742
+ [yellow]llm routing on / off[/yellow] - Enable or disable automatic model assignment
743
+ """
744
+ console.print(Panel(help_text.strip(), title="System Help", border_style="cyan"))
745
+
746
+ # ------------------------------------------------------------------ #
747
+ # Directory navigation
748
+ # ------------------------------------------------------------------ #
749
+ def change_directory(self, target: str):
750
+ target = target.strip()
751
+ if not target:
752
+ self.print_projects_table()
753
+ return
754
+
755
+ if target.isdigit():
756
+ if target in self.registry:
757
+ proj_data = self.registry[target]
758
+ if not os.path.isdir(proj_data["path"]):
759
+ console.print(
760
+ f"[bold red]Error:[/bold red] Directory "
761
+ f"'{proj_data['path']}' does not exist."
762
+ )
763
+ return
764
+ self.proj = proj_data
765
+ self.sandbox = FolderSandbox(self.proj["path"])
766
+ self.logger = ChangelogEngine(project_root=self.proj["path"])
767
+ if MemoryManager:
768
+ self.memory = MemoryManager(
769
+ db_path=os.path.join(self.proj["path"], "memory.db")
770
+ )
771
+ self.adr = SessionADR(self.proj["path"])
772
+ self.validator = ContractValidator(self.proj["path"])
773
+ self._guard = DriftGuard(self.proj["path"])
774
+ self.should_pause = False
775
+ self.should_stop = False
776
+ console.print(
777
+ f"[bold green]Switched to registered project [{target}]:[/bold green] "
778
+ f"{self.proj['name']} ([cyan]{self.proj['path']}[/cyan])"
779
+ )
780
+ return
781
+ else:
782
+ console.print(
783
+ f"[bold red]Error:[/bold red] Project key [{target}] not found."
784
+ )
785
+ return
786
+
787
+ expanded = os.path.expanduser(target)
788
+ if os.path.isabs(expanded):
789
+ abs_path = os.path.realpath(expanded)
790
+ else:
791
+ base_dir = (
792
+ self.proj["path"]
793
+ if (self.proj and os.path.isdir(self.proj["path"]))
794
+ else os.getcwd()
795
+ )
796
+ abs_path = os.path.realpath(os.path.join(base_dir, expanded))
797
+
798
+ if not os.path.isdir(abs_path):
799
+ console.print(
800
+ f"[bold red]Error:[/bold red] Directory '{abs_path}' does not exist."
801
+ )
802
+ return
803
+
804
+ for k, v in self.registry.items():
805
+ if os.path.abspath(v["path"]) == abs_path:
806
+ self.proj = v
807
+ self.sandbox = FolderSandbox(self.proj["path"])
808
+ self.logger = ChangelogEngine(project_root=self.proj["path"])
809
+ if MemoryManager:
810
+ self.memory = MemoryManager(
811
+ db_path=os.path.join(self.proj["path"], "memory.db")
812
+ )
813
+ self.adr = SessionADR(self.proj["path"])
814
+ self.validator = ContractValidator(self.proj["path"])
815
+ self._guard = DriftGuard(self.proj["path"])
816
+ self.should_pause = False
817
+ self.should_stop = False
818
+ console.print(
819
+ f"[bold green]Switched to registered project [{k}]:[/bold green] "
820
+ f"{v['name']}"
821
+ )
822
+ return
823
+
824
+ lang = ".py"
825
+ ignored_dirs = {
826
+ ".git",
827
+ "node_modules",
828
+ "__pycache__",
829
+ ".venv",
830
+ "venv",
831
+ "bin",
832
+ "obj",
833
+ "build",
834
+ }
835
+
836
+ for root, dirs, files in os.walk(abs_path):
837
+ dirs[:] = [d for d in dirs if d not in ignored_dirs]
838
+ for file in files:
839
+ if file.endswith(".cs"):
840
+ lang = ".cs"
841
+ break
842
+ elif file.endswith(".luau") or file.endswith(".lua"):
843
+ lang = ".luau"
844
+ break
845
+ if lang != ".py":
846
+ break
847
+
848
+ new_key = str(len(self.registry) + 1)
849
+ folder_name = os.path.basename(abs_path) or "Project"
850
+ new_proj = {"name": folder_name, "path": abs_path, "lang": lang}
851
+
852
+ self.registry[new_key] = new_proj
853
+ save_registry(self.registry)
854
+
855
+ self.proj = new_proj
856
+ self.sandbox = FolderSandbox(self.proj["path"])
857
+ self.logger = ChangelogEngine(project_root=self.proj["path"])
858
+ if MemoryManager:
859
+ self.memory = MemoryManager(
860
+ db_path=os.path.join(self.proj["path"], "memory.db")
861
+ )
862
+ self.adr = SessionADR(self.proj["path"])
863
+ self.validator = ContractValidator(self.proj["path"])
864
+ self._guard = DriftGuard(self.proj["path"])
865
+ self.should_pause = False
866
+ self.should_stop = False
867
+ console.print(
868
+ f"[bold green]Registered and switched to new project [{new_key}]:[/bold green] "
869
+ f"{folder_name} ([cyan]{abs_path}[/cyan])"
870
+ )
871
+
872
+ # ------------------------------------------------------------------ #
873
+ # Task command
874
+ # ------------------------------------------------------------------ #
875
+ def handle_task_command(self, prompt: str):
876
+ prompt = prompt.strip()
877
+
878
+ if prompt.startswith("-f "):
879
+ filepath = prompt[3:].strip()
880
+ try:
881
+ with open(filepath, "r", encoding="utf-8") as f:
882
+ prompt = f.read().strip()
883
+ console.print(f"[green]Prompt loaded from {filepath} ({len(prompt)} chars)[/green]")
884
+ except Exception as e:
885
+ console.print(f"[bold red]Failed to read {filepath}: {e}[/bold red]")
886
+ return
887
+
888
+ if not prompt:
889
+ console.print("[bold yellow]Enter a multi-line prompt. Type 'EOF' on a new line to finish.[/bold yellow]")
890
+ lines = []
891
+ while True:
892
+ try:
893
+ line = console.input()
894
+ except (KeyboardInterrupt, EOFError):
895
+ console.print("\n[yellow]Cancelled.[/yellow]")
896
+ return
897
+ if line.strip() == "EOF":
898
+ break
899
+ lines.append(line)
900
+ prompt = "\n".join(lines).strip()
901
+
902
+ if not prompt:
903
+ console.print("[bold red]Prompt cannot be empty. Cancelled.[/bold red]")
904
+ return
905
+
906
+ if not self.proj or not os.path.isdir(self.proj["path"]):
907
+ console.print(
908
+ "[bold red]Error:[/bold red] No valid workspace selected. "
909
+ "Use 'cd <path>' first."
910
+ )
911
+ return
912
+
913
+ app_state = build_state_template(user_prompt=prompt)
914
+ self.save_state(app_state)
915
+ state = self.load_state()
916
+
917
+ live_project_tree = get_project_context(self.proj["path"])
918
+ state["project_files"] = live_project_tree
919
+
920
+ console.print(
921
+ Panel(
922
+ f"[bold cyan]Bootstrapping Task:[/bold cyan]\n{prompt}",
923
+ border_style="cyan",
924
+ )
925
+ )
926
+
927
+ session_ctx = self._build_session_context(
928
+ state, role="researcher", record_trigger=True
929
+ )
930
+
931
+ with console.status(
932
+ "[cyan]Researcher agent structuring plan and requirements...",
933
+ spinner="dots",
934
+ ):
935
+ state, meta = run_researcher(
936
+ state,
937
+ self.proj["name"],
938
+ project_dir=self.proj["path"],
939
+ project_files=live_project_tree,
940
+ session_context=session_ctx,
941
+ )
942
+
943
+ self.update_costs(meta, agent_name="researcher")
944
+
945
+ research_data = state.get("research_data", {})
946
+ res_payload = (
947
+ research_data
948
+ if isinstance(research_data, dict)
949
+ else {"raw_response": research_data}
950
+ )
951
+
952
+ app_state = AppState.model_validate(state)
953
+ app_state = apply_researcher_plan(app_state, res_payload)
954
+
955
+ # Predict costs for the plan
956
+ try:
957
+ cost_prediction = predict_cost(list(app_state.plan), task_prompt=prompt)
958
+ if not hasattr(app_state, "metrics") or app_state.metrics is None:
959
+ app_state.metrics = {}
960
+ if isinstance(app_state.metrics, dict):
961
+ app_state.metrics["predicted_cost"] = cost_prediction
962
+ else:
963
+ setattr(app_state.metrics, "predicted_cost", cost_prediction)
964
+
965
+ # Rich Table для вывода прогноза
966
+ cost_table = Table(title="Predicted Cost", show_header=True, header_style="bold green")
967
+ cost_table.add_column("Step", style="cyan")
968
+ cost_table.add_column("Model", style="yellow")
969
+ cost_table.add_column("Complexity", style="magenta")
970
+ cost_table.add_column("~USD", justify="right", style="green")
971
+
972
+ for step in cost_prediction.get("by_step", []):
973
+ cost_table.add_row(
974
+ str(step.get("title", "Unknown"))[:40],
975
+ str(step.get("model", "unknown")),
976
+ str(step.get("complexity", "unknown")),
977
+ f"${step.get('cost_usd', 0.0):.4f}"
978
+ )
979
+
980
+ cost_table.add_row(
981
+ "TOTAL (with 30% overhead)", "", "",
982
+ f"${cost_prediction.get('total_usd', 0.0):.4f}",
983
+ style="bold green"
984
+ )
985
+ console.print(cost_table)
986
+ except Exception as cost_exc:
987
+ console.print(f"[dim yellow]Cost prediction failed: {cost_exc}[/dim yellow]")
988
+
989
+ self.save_state(app_state)
990
+ state = self.load_state()
991
+
992
+ log_reasoning(
993
+ "researcher",
994
+ "research",
995
+ {
996
+ "analysis": state.get("task_spec", {}).get("decomposed_goal", ""),
997
+ "plan": state.get("plan", []),
998
+ },
999
+ project_root=self.proj["path"],
1000
+ )
1001
+
1002
+ plan = state.get("plan", [])
1003
+ if not plan:
1004
+ console.print(
1005
+ Panel(
1006
+ f"[bold red]Researcher produced no plan.[/bold red] "
1007
+ f"{state.get('research_error', '')}\n"
1008
+ "Check model/token limits ('llm' command) and retry.",
1009
+ border_style="red",
1010
+ )
1011
+ )
1012
+
1013
+ target_file = state.get("target_file") or (
1014
+ research_data.get("target_file")
1015
+ if isinstance(research_data, dict)
1016
+ else None
1017
+ )
1018
+ active_subtask = state.get("active_subtask") or (
1019
+ plan[0].get("title") if plan and isinstance(plan[0], dict) else None
1020
+ )
1021
+
1022
+ console.print(
1023
+ Panel(
1024
+ f"[bold green]Task Bootstrapped Successfully![/bold green]\n"
1025
+ f"Target File: [cyan]{target_file or 'Not specified / Needs investigation'}[/cyan]\n"
1026
+ f"Active Subtask: [yellow]{active_subtask or 'None'}[/yellow]\n"
1027
+ f"Plan Steps: [white]{len(plan)} step(s) identified[/white]\n\n"
1028
+ f"Type [bold cyan]run[/bold cyan] to start autonomous execution.",
1029
+ border_style="green",
1030
+ )
1031
+ )
1032
+
1033
+ # ------------------------------------------------------------------ #
1034
+ # Lesson command
1035
+ # ------------------------------------------------------------------ #
1036
+ def handle_lesson_command(self, args: str):
1037
+ parts = args.strip().split()
1038
+ if not parts:
1039
+ console.print(
1040
+ "[bold yellow]Usage:[/bold yellow] lesson "
1041
+ "[list | candidate | promote | deprecate] <filename>"
1042
+ )
1043
+ return
1044
+
1045
+ action = parts[0].lower()
1046
+
1047
+ if action == "list":
1048
+ for cat, path in LESSONS_DIRS.items():
1049
+ files = (
1050
+ [f for f in os.listdir(path) if f.endswith(".json")]
1051
+ if os.path.exists(path)
1052
+ else []
1053
+ )
1054
+ console.print(
1055
+ f"[bold cyan]{cat.upper()}:[/bold cyan] {len(files)} files"
1056
+ )
1057
+ for f in files:
1058
+ console.print(f" - {f}")
1059
+ return
1060
+
1061
+ if action in ("candidate", "promote", "deprecate"):
1062
+ if len(parts) < 2:
1063
+ console.print(
1064
+ f"[bold red]Error:[/bold red] Missing target filename "
1065
+ f"for 'lesson {action}'."
1066
+ )
1067
+ return
1068
+ filename = parts[1]
1069
+ try:
1070
+ if action == "candidate":
1071
+ success = move_to_candidates(
1072
+ filename, source_category="observations"
1073
+ )
1074
+ elif action == "promote":
1075
+ success = move_to_promoted(
1076
+ filename, source_category="candidates"
1077
+ )
1078
+ else:
1079
+ success = move_to_deprecated(
1080
+ filename, source_category="promoted"
1081
+ )
1082
+
1083
+ if success:
1084
+ console.print(
1085
+ f"[bold green]Success:[/bold green] Lesson '{filename}' updated."
1086
+ )
1087
+ else:
1088
+ console.print(
1089
+ f"[bold red]Error:[/bold red] File '{filename}' not found."
1090
+ )
1091
+ except Exception as e:
1092
+ console.print(f"[bold red]Failed to update lesson:[/bold red] {e}")
1093
+ return
1094
+
1095
+ console.print(f"[bold red]Unknown lesson action:[/bold red] '{action}'")
1096
+
1097
+ # ------------------------------------------------------------------ #
1098
+ # Handshake command
1099
+ # ------------------------------------------------------------------ #
1100
+ def handle_handshake_command(self, args: str):
1101
+ role = args.strip().lower() or "researcher"
1102
+ if not self.proj or not os.path.isdir(self.proj["path"]):
1103
+ console.print(
1104
+ "[bold red]Error:[/bold red] No valid workspace selected."
1105
+ )
1106
+ return
1107
+
1108
+ state = self.load_state()
1109
+ try:
1110
+ result = build_session_context(
1111
+ project_root=self.proj["path"],
1112
+ state=state,
1113
+ role=role,
1114
+ )
1115
+ except Exception as e:
1116
+ console.print(f"[bold red]Handshake failed:[/bold red] {e}")
1117
+ return
1118
+
1119
+ console.print(
1120
+ Panel(
1121
+ result.text or "(empty)",
1122
+ title=f"Handshake preview — role={role}",
1123
+ border_style="cyan",
1124
+ )
1125
+ )
1126
+ console.print(
1127
+ f"[dim]Sections: {', '.join(result.sections_present) or 'none'} | "
1128
+ f"~{result.estimated_tokens} tokens | "
1129
+ f"lessons: {len(result.lesson_node_ids)} nodes[/dim]"
1130
+ )
1131
+
1132
+ # ------------------------------------------------------------------ #
1133
+ # Coherence command
1134
+ # ------------------------------------------------------------------ #
1135
+ def handle_coherence_command(self, args: str):
1136
+ if not self.proj or not os.path.isdir(self.proj["path"]):
1137
+ console.print(
1138
+ "[bold red]Error:[/bold red] No valid workspace selected."
1139
+ )
1140
+ return
1141
+
1142
+ try:
1143
+ report = check_coherence(self.proj["path"])
1144
+ except Exception as exc:
1145
+ console.print(f"[bold red]Coherence check failed:[/bold red] {exc}")
1146
+ return
1147
+
1148
+ border = "green" if report.ok else "yellow"
1149
+ console.print(
1150
+ Panel(
1151
+ render_coherence_report(report),
1152
+ title="Coherence Report",
1153
+ border_style=border,
1154
+ )
1155
+ )
1156
+ console.print(
1157
+ f"[dim]Files checked: {len(report.files_checked)} | "
1158
+ f"HTML classes: {len(report.html_classes)} | "
1159
+ f"CSS selectors: {len(report.css_selectors)} | "
1160
+ f"errors: {len(report.errors)} | warnings: {len(report.warnings)}[/dim]"
1161
+ )
1162
+
1163
+ # ------------------------------------------------------------------ #
1164
+ # Plugin command
1165
+ # ------------------------------------------------------------------ #
1166
+ def handle_plugin_command(self, args):
1167
+ from rich.markup import escape
1168
+
1169
+ usage = (
1170
+ "[bold yellow]Usage:[/bold yellow] "
1171
+ "plugin list | plugin install <path> | plugin remove <id>"
1172
+ )
1173
+
1174
+ if self.plugin_mgr is None and self.proj and os.path.isdir(self.proj["path"]):
1175
+ try:
1176
+ self.plugin_mgr = PluginManager(self.proj["path"])
1177
+ except Exception:
1178
+ self.plugin_mgr = None
1179
+
1180
+ if self.plugin_mgr is None:
1181
+ console.print(
1182
+ Panel(
1183
+ "[bold red]No active project — plugin manager is unavailable.[/bold red]\n"
1184
+ "Use 'cd <path>' to select a workspace first.",
1185
+ title="Plugins",
1186
+ border_style="red",
1187
+ )
1188
+ )
1189
+ return
1190
+
1191
+ if isinstance(args, (list, tuple)):
1192
+ tokens = [str(a) for a in args]
1193
+ else:
1194
+ tokens = str(args or "").strip().split(maxsplit=1)
1195
+
1196
+ if not tokens:
1197
+ console.print(usage)
1198
+ return
1199
+
1200
+ action = tokens[0].lower()
1201
+ rest = ""
1202
+ if len(tokens) > 1:
1203
+ rest = " ".join(tokens[1:]).strip() if isinstance(args, (list, tuple)) else tokens[1].strip()
1204
+
1205
+ if action == "list":
1206
+ try:
1207
+ plugins = self.plugin_mgr.list_plugins() or []
1208
+ except Exception as exc:
1209
+ console.print(
1210
+ Panel(
1211
+ f"[bold red]Failed to list plugins:[/bold red] {escape(str(exc))}",
1212
+ title="Plugins",
1213
+ border_style="red",
1214
+ )
1215
+ )
1216
+ return
1217
+
1218
+ if not plugins:
1219
+ console.print("[dim]No plugins installed[/dim]")
1220
+ return
1221
+
1222
+ table = Table(
1223
+ title="Installed Plugins",
1224
+ show_header=True,
1225
+ header_style="bold cyan",
1226
+ )
1227
+ table.add_column("ID", style="bold yellow")
1228
+ table.add_column("Name", style="white")
1229
+ table.add_column("Version", style="magenta")
1230
+ table.add_column("Author", style="green")
1231
+
1232
+ def _field(item, key):
1233
+ if isinstance(item, dict):
1234
+ value = item.get(key)
1235
+ else:
1236
+ value = getattr(item, key, None)
1237
+ if value is None or value == "":
1238
+ return "-"
1239
+ return escape(str(value))
1240
+
1241
+ for item in plugins:
1242
+ if isinstance(item, str):
1243
+ table.add_row(escape(item), "-", "-", "-")
1244
+ continue
1245
+ table.add_row(
1246
+ _field(item, "id"),
1247
+ _field(item, "name"),
1248
+ _field(item, "version"),
1249
+ _field(item, "author"),
1250
+ )
1251
+ console.print(table)
1252
+ return
1253
+
1254
+ if action == "install":
1255
+ if not rest:
1256
+ console.print("[bold yellow]Usage:[/bold yellow] plugin install <path>")
1257
+ return
1258
+ plugin_path = os.path.expanduser(rest.strip().strip('"').strip("'"))
1259
+ try:
1260
+ result = self.plugin_mgr.install(plugin_path)
1261
+ except Exception as exc:
1262
+ console.print(
1263
+ Panel(
1264
+ f"[bold red]Plugin install failed:[/bold red] {escape(str(exc))}",
1265
+ title="Plugins",
1266
+ border_style="red",
1267
+ )
1268
+ )
1269
+ return
1270
+
1271
+ ok = False
1272
+ message = ""
1273
+ if isinstance(result, tuple):
1274
+ ok = bool(result[0]) if result else False
1275
+ message = str(result[1]) if len(result) > 1 and result[1] is not None else ""
1276
+ elif isinstance(result, dict):
1277
+ ok = bool(result.get("ok", result.get("success", False)))
1278
+ message = str(
1279
+ result.get("message") or result.get("error") or result.get("id") or ""
1280
+ )
1281
+ else:
1282
+ ok = bool(result)
1283
+ message = result if isinstance(result, str) else ""
1284
+
1285
+ if ok:
1286
+ console.print(
1287
+ Panel(
1288
+ f"[bold green]Plugin installed from {escape(plugin_path)}[/bold green]"
1289
+ + (f"\n{escape(message)}" if message else ""),
1290
+ title="Plugins",
1291
+ border_style="green",
1292
+ )
1293
+ )
1294
+ else:
1295
+ console.print(
1296
+ Panel(
1297
+ f"[bold red]Plugin install failed for {escape(plugin_path)}[/bold red]"
1298
+ + (f"\n{escape(message)}" if message else ""),
1299
+ title="Plugins",
1300
+ border_style="red",
1301
+ )
1302
+ )
1303
+ return
1304
+
1305
+ if action == "remove":
1306
+ if not rest:
1307
+ console.print("[bold yellow]Usage:[/bold yellow] plugin remove <id>")
1308
+ return
1309
+ plugin_id = rest.strip()
1310
+ try:
1311
+ removed = self.plugin_mgr.remove(plugin_id)
1312
+ except Exception as exc:
1313
+ console.print(
1314
+ Panel(
1315
+ f"[bold red]Plugin removal failed:[/bold red] {escape(str(exc))}",
1316
+ title="Plugins",
1317
+ border_style="red",
1318
+ )
1319
+ )
1320
+ return
1321
+
1322
+ if removed:
1323
+ console.print(
1324
+ Panel(
1325
+ f"[bold green]Plugin '{escape(plugin_id)}' removed.[/bold green]",
1326
+ title="Plugins",
1327
+ border_style="green",
1328
+ )
1329
+ )
1330
+ else:
1331
+ console.print(
1332
+ Panel(
1333
+ f"[bold red]Plugin '{escape(plugin_id)}' not found or could not be removed.[/bold red]",
1334
+ title="Plugins",
1335
+ border_style="red",
1336
+ )
1337
+ )
1338
+ return
1339
+
1340
+ console.print(f"[bold red]Unknown plugin action:[/bold red] '{escape(action)}'")
1341
+ console.print(usage)
1342
+
1343
+ # ------------------------------------------------------------------ #
1344
+ # Task bootstrap
1345
+ # ------------------------------------------------------------------ #
1346
+ def ensure_active_task(self, prompt_arg: str = "") -> bool:
1347
+ prompt_arg = prompt_arg.strip()
1348
+
1349
+ if prompt_arg:
1350
+ app_state = build_state_template(user_prompt=prompt_arg)
1351
+ self.save_state(app_state)
1352
+ console.print(f"[bold green]Принята новая задача:[/bold green] {prompt_arg}\n")
1353
+ return True
1354
+
1355
+ state = self.load_state() or {}
1356
+ raw_prompt = (
1357
+ state.get("task_prompt")
1358
+ or state.get("user_prompt")
1359
+ or state.get("task_spec", {}).get("raw_user_prompt")
1360
+ or ""
1361
+ ).strip()
1362
+
1363
+ status = str(state.get("status", "")).lower()
1364
+ current_step = str(state.get("current_step", "")).lower()
1365
+ is_finished = status in ("completed", "failed", "done") or current_step in (
1366
+ "completed",
1367
+ "failed",
1368
+ "done",
1369
+ )
1370
+
1371
+ if not raw_prompt or is_finished:
1372
+ if is_finished:
1373
+ console.print(
1374
+ "[bold yellow]Предыдущая задача уже завершена.[/bold yellow]"
1375
+ )
1376
+
1377
+ console.print("[bold yellow]Enter a multi-line prompt. Type 'EOF' on a new line to finish.[/bold yellow]")
1378
+ lines = []
1379
+ while True:
1380
+ try:
1381
+ line = console.input()
1382
+ except (KeyboardInterrupt, EOFError):
1383
+ console.print("\n[yellow]Ввод отменен.[/yellow]")
1384
+ _drain_stdin()
1385
+ return False
1386
+ if line.strip() == "EOF":
1387
+ break
1388
+ lines.append(line)
1389
+
1390
+ user_input = "\n".join(lines).strip()
1391
+
1392
+ if not user_input:
1393
+ console.print(
1394
+ "[bold red]Промпт не может быть пустым. Запуск отменен.[/bold red]"
1395
+ )
1396
+ return False
1397
+
1398
+ app_state = build_state_template(user_prompt=user_input)
1399
+ self.save_state(app_state)
1400
+ console.print(
1401
+ "[green]Состояние инициализировано с новой задачей.[/green]\n"
1402
+ )
1403
+ return True
1404
+
1405
+ if current_step in (None, "", "idle", "init", "none"):
1406
+ state["current_step"] = "research"
1407
+ state["status"] = "in_progress"
1408
+ self.save_state(state)
1409
+
1410
+ return True
1411
+
1412
+ # ------------------------------------------------------------------ #
1413
+ # Pipeline execution
1414
+ # ------------------------------------------------------------------ #
1415
+ def run_agent_cycle(self, prompt_arg: str = ""):
1416
+ if not self.proj or not os.path.isdir(self.proj["path"]):
1417
+ console.print(
1418
+ "[bold red]Error:[/bold red] No valid workspace selected. "
1419
+ "Use 'cd <path>' first."
1420
+ )
1421
+ return
1422
+
1423
+ if not self.sandbox or not self.logger:
1424
+ self.sandbox = FolderSandbox(self.proj["path"])
1425
+ self.logger = ChangelogEngine(project_root=self.proj["path"])
1426
+
1427
+ if self._guard is None:
1428
+ self._guard = DriftGuard(self.proj["path"])
1429
+
1430
+ if not self.ensure_active_task(prompt_arg):
1431
+ return
1432
+
1433
+ try:
1434
+ sync_active_models_specs()
1435
+ except Exception as e:
1436
+ console.print(
1437
+ f"[dim yellow]Notice: Model specs sync skipped ({e})[/dim yellow]"
1438
+ )
1439
+
1440
+ console.print(
1441
+ Panel(
1442
+ f"[bold green]Starting execution pipeline...[/bold green]\n"
1443
+ f"Target: [white]{self.proj['name']}[/white] "
1444
+ f"([cyan]{self.proj['path']}[/cyan])",
1445
+ border_style="green",
1446
+ )
1447
+ )
1448
+
1449
+ self.should_pause = False
1450
+ self.should_stop = False
1451
+
1452
+ hitl_interval = getattr(config, "HITL_EVERY_N_CYCLES", 3)
1453
+
1454
+ with console.status(
1455
+ "[bold blue]Updating project structure tree...", spinner="dots"
1456
+ ):
1457
+ struct_res = generate_or_update_structure(self.proj["path"])
1458
+ console.print(
1459
+ f"[green]Project structure synchronized.[/green] "
1460
+ f"Tracked files: [yellow]{struct_res['file_count']}[/yellow]"
1461
+ )
1462
+
1463
+ res_file = None
1464
+ current_diff = None
1465
+ old_code_backup = ""
1466
+
1467
+ while True:
1468
+ if self.should_stop:
1469
+ console.print("[bold red]Execution stopped.[/bold red]")
1470
+ self.should_stop = False
1471
+ break
1472
+
1473
+ if self.should_pause:
1474
+ console.print("[bold yellow]Execution paused.[/bold yellow]")
1475
+ self.should_pause = False
1476
+ break
1477
+
1478
+ state = self.load_state()
1479
+ iteration = state.get("iteration_count", 0)
1480
+
1481
+ live_project_tree = get_project_context(self.proj["path"])
1482
+ state["project_files"] = live_project_tree
1483
+
1484
+ # ---- HITL checkpoint ----
1485
+ if iteration > 0 and iteration % hitl_interval == 0:
1486
+ console.print(
1487
+ Panel(
1488
+ f"[bold yellow]HITL Checkpoint Reached "
1489
+ f"({iteration} cycles completed)[/bold yellow]\n"
1490
+ "Options: [C]ontinue | [F]eedback | [S]top",
1491
+ border_style="yellow",
1492
+ )
1493
+ )
1494
+ try:
1495
+ cmd = console.input(
1496
+ "[yellow]Select action ([C]/[F]/[S]): [/yellow]"
1497
+ ).strip().lower()
1498
+ except (KeyboardInterrupt, EOFError):
1499
+ console.print(
1500
+ "\n[yellow]HITL input cancelled. Pausing execution...[/yellow]"
1501
+ )
1502
+ _drain_stdin()
1503
+ break
1504
+ finally:
1505
+ _drain_stdin()
1506
+
1507
+ if cmd == "f":
1508
+ try:
1509
+ feedback_text = console.input(
1510
+ "[bold cyan]Enter feedback for agents: [/bold cyan]"
1511
+ ).strip()
1512
+ state["human_feedback"] = feedback_text
1513
+ state["current_step"] = "research"
1514
+ self.save_state(state)
1515
+ console.print(
1516
+ f"[bold green]Feedback logged:[/bold green] {feedback_text}"
1517
+ )
1518
+ except (KeyboardInterrupt, EOFError):
1519
+ _drain_stdin()
1520
+ break
1521
+ finally:
1522
+ _drain_stdin()
1523
+ elif cmd != "c":
1524
+ break
1525
+
1526
+ # ---- Resource limits ----
1527
+ if (
1528
+ iteration >= config.MAX_ITERATIONS
1529
+ or self.total_cost >= config.MAX_BUDGET_USD
1530
+ ):
1531
+ console.print(
1532
+ Panel(
1533
+ "[bold red]Resource Limit Reached![/bold red]",
1534
+ border_style="red",
1535
+ )
1536
+ )
1537
+ try:
1538
+ cmd = console.input(
1539
+ "[yellow]Select Action ([C]ontinue / [S]top): [/yellow]"
1540
+ ).strip().lower()
1541
+ except (KeyboardInterrupt, EOFError):
1542
+ _drain_stdin()
1543
+ break
1544
+ finally:
1545
+ _drain_stdin()
1546
+ if cmd == "c":
1547
+ state["iteration_count"] = 0
1548
+ self.save_state(state)
1549
+ continue
1550
+ else:
1551
+ break
1552
+ # Periodic self-reflection: every N iterations, insert a reflect pass
1553
+ # before the normal step dispatch.
1554
+ step = state.get("current_step")
1555
+
1556
+ # Prevent re-triggering reflection on the same iteration
1557
+ last_reflect = state.get("metrics", {}).get("last_reflection", {}).get("iteration", -1)
1558
+ if (
1559
+ step not in ("reflect", "done", "completed")
1560
+ and iteration > 0
1561
+ and iteration % getattr(config, "REFLECTION_INTERVAL", 5) == 0
1562
+ and iteration != last_reflect
1563
+ ):
1564
+ state["_pre_reflect_step"] = step
1565
+ state["current_step"] = "reflect"
1566
+ self.save_state(state)
1567
+ continue
1568
+
1569
+ # ============================================================
1570
+ # STEP 1: RESEARCH & PLANNING
1571
+ # ============================================================
1572
+ if step == "research":
1573
+ console.rule("[bold cyan]Step 1: Research and Planning[/bold cyan]")
1574
+
1575
+ state["hardware_constraints"] = state.get("system_constraints", [])
1576
+
1577
+ session_ctx = self._build_session_context(
1578
+ state, role="researcher", record_trigger=True
1579
+ )
1580
+
1581
+ with console.status(
1582
+ "[cyan]Researcher agent decomposing prompt and building plan...",
1583
+ spinner="dots",
1584
+ ):
1585
+ state_updated, meta = run_researcher(
1586
+ state,
1587
+ self.proj["name"],
1588
+ project_dir=self.proj["path"],
1589
+ project_files=live_project_tree,
1590
+ session_context=session_ctx,
1591
+ )
1592
+
1593
+ self.update_costs(meta, agent_name="researcher")
1594
+
1595
+ research_data = state_updated.get("research_data", {})
1596
+ res_payload = (
1597
+ research_data
1598
+ if isinstance(research_data, dict)
1599
+ else {"raw_response": research_data}
1600
+ )
1601
+
1602
+ app_state = AppState.model_validate(state_updated)
1603
+ app_state = apply_researcher_plan(app_state, res_payload)
1604
+
1605
+ final_plan = list(app_state.plan)
1606
+ pending_count = sum(1 for s in final_plan if s.status == "pending")
1607
+
1608
+ state_to_save = app_state.model_dump()
1609
+ state_to_save["research_error"] = state_updated.get("research_error")
1610
+ state_to_save["research_analysis"] = state_updated.get(
1611
+ "research_analysis"
1612
+ )
1613
+ state_to_save["active_lesson_node_ids"] = state_updated.get(
1614
+ "active_lesson_node_ids"
1615
+ ) or []
1616
+
1617
+ log_reasoning(
1618
+ "researcher",
1619
+ "research",
1620
+ {
1621
+ "analysis": app_state.task_spec.decomposed_goal
1622
+ or state_updated.get("research_analysis", ""),
1623
+ "plan": [s.model_dump() for s in final_plan],
1624
+ },
1625
+ project_root=self.proj["path"],
1626
+ )
1627
+
1628
+ if pending_count == 0:
1629
+ reason = (
1630
+ state_updated.get("research_error") or "no pending steps"
1631
+ )
1632
+ console.print(
1633
+ Panel(
1634
+ f"[bold red]Researcher returned no usable plan:[/bold red] "
1635
+ f"{reason}\n"
1636
+ "Retrying research (counts toward iteration limit).",
1637
+ border_style="red",
1638
+ )
1639
+ )
1640
+ state_to_save["current_step"] = "research"
1641
+ state_to_save["iteration_count"] = (
1642
+ state.get("iteration_count", 0) + 1
1643
+ )
1644
+ self.save_state(state_to_save)
1645
+ continue
1646
+
1647
+ state_to_save["current_step"] = "edit"
1648
+ self.save_state(state_to_save)
1649
+
1650
+ console.print(
1651
+ f"[bold green]Plan generated with {pending_count} pending "
1652
+ f"subtask(s) ({len(final_plan)} total).[/bold green]"
1653
+ )
1654
+
1655
+ # ============================================================
1656
+ # STEP 2: EDIT (code generation)
1657
+ # ============================================================
1658
+ elif step == "edit":
1659
+ console.rule("[bold yellow]Step 2: Code Generation[/bold yellow]")
1660
+
1661
+ pending_steps = [
1662
+ s
1663
+ for s in state.get("plan", [])
1664
+ if isinstance(s, dict) and s.get("status") == "pending"
1665
+ ]
1666
+ if not pending_steps:
1667
+ # SAFETY CHECK: Did the agent actually finish a web task?
1668
+ is_web_task = (
1669
+ os.path.exists(os.path.join(self.proj["path"], "index.html")) or
1670
+ "index.html" in str(state.get("pipeline_created_files", []))
1671
+ )
1672
+
1673
+ if is_web_task:
1674
+ missing_files = []
1675
+ if not os.path.exists(os.path.join(self.proj["path"], "styles.css")):
1676
+ missing_files.append("styles.css")
1677
+ if not os.path.exists(os.path.join(self.proj["path"], "script.js")):
1678
+ missing_files.append("script.js")
1679
+ if not os.path.exists(os.path.join(self.proj["path"], "llms.txt")):
1680
+ missing_files.append("llms.txt")
1681
+
1682
+ if missing_files:
1683
+ console.print(
1684
+ f"[bold yellow]WARNING: Plan complete, but web files are missing: "
1685
+ f"{', '.join(missing_files)}. Forcing Research phase to generate them.[/bold yellow]"
1686
+ )
1687
+ state["editor_feedback"] = (
1688
+ f"CRITICAL: You marked the plan as complete, but the following mandatory "
1689
+ f"web files are missing: {', '.join(missing_files)}. "
1690
+ f"You must create a new plan to generate these files."
1691
+ )
1692
+ state["current_step"] = "research"
1693
+ self.save_state(state)
1694
+ continue
1695
+
1696
+ console.print(
1697
+ "[green]No pending steps remaining. Task is complete.[/green]"
1698
+ )
1699
+ state["current_step"] = "done"
1700
+ state["status"] = "completed"
1701
+ self.save_state(state)
1702
+ continue
1703
+
1704
+ current_subtask = pending_steps[0]
1705
+ state["active_subtask"] = current_subtask.get("title")
1706
+ state["target_file"] = current_subtask.get(
1707
+ "target_file", state.get("target_file")
1708
+ )
1709
+
1710
+ session_ctx = self._build_session_context(
1711
+ state, role="editor", record_trigger=True
1712
+ )
1713
+
1714
+ resolved_target = state.get("target_file") or ""
1715
+ related_ctx = (
1716
+ self._build_related_context(state, resolved_target)
1717
+ if resolved_target
1718
+ else ""
1719
+ )
1720
+
1721
+ try:
1722
+ with console.status(
1723
+ "[yellow]Editor agent generating code changes...",
1724
+ spinner="dots",
1725
+ ):
1726
+ state, meta = run_editor(
1727
+ state,
1728
+ project_files=live_project_tree,
1729
+ project_dir=self.proj["path"],
1730
+ session_context=session_ctx,
1731
+ related_context=related_ctx,
1732
+ )
1733
+ self.update_costs(meta, agent_name="editor")
1734
+ # === BEHAVIOR GUARD CHECK ===
1735
+ # Pass project_root to state so guard can check paths
1736
+ state["project_root"] = self.proj["path"]
1737
+ halt_reason = self.behavior_guard.observe_attempt(state, meta)
1738
+
1739
+ if halt_reason:
1740
+ console.print(
1741
+ Panel(
1742
+ f"[bold red]CIRCUIT BREAKER (Behavior Guard)[/bold red]\n\n"
1743
+ f"{halt_reason}\n\n"
1744
+ f"The pipeline has been halted to prevent token waste "
1745
+ f"and potential sandbox escape.",
1746
+ border_style="bold red",
1747
+ )
1748
+ )
1749
+ state["status"] = "failed"
1750
+ state["current_step"] = "done"
1751
+ self.save_state(state)
1752
+ break # Breaks the while loop
1753
+ except Exception as e:
1754
+ err_msg = str(e)
1755
+ console.print(f"[bold red]Editor Error:[/bold red] {err_msg}")
1756
+
1757
+ # Если ответ обрезался по лимиту токенов — даем явную инструкцию
1758
+ if "truncated due to max_tokens limit" in err_msg:
1759
+ state["editor_feedback"] = (
1760
+ "CRITICAL ERROR: Your previous response was too long and got cut off. "
1761
+ "You MUST output the code in smaller, manageable chunks (e.g., max 150 lines at a time). "
1762
+ "If you are doing a full rewrite, stop and wait for the next cycle to append more."
1763
+ )
1764
+ state["edit_retries"] = state.get("edit_retries", 0) + 1
1765
+ if state["edit_retries"] >= 3:
1766
+ console.print("[bold red]Editor failed 3 times due to truncation. Reverting to Research.[/bold red]")
1767
+ state["current_step"] = "research"
1768
+ state["edit_retries"] = 0
1769
+ state["research_error"] = "Generated code was too large. You must split the plan into smaller files or parts."
1770
+ else:
1771
+ state["current_step"] = "edit"
1772
+ else:
1773
+ state["iteration_count"] = state.get("iteration_count", 0) + 1
1774
+
1775
+ self.save_state(state)
1776
+ continue
1777
+
1778
+ # ---- Patch application failure ----
1779
+ raw_generated = state.get("generated_code", {})
1780
+ apply_res = (
1781
+ raw_generated.get("apply_result", {})
1782
+ if isinstance(raw_generated, dict)
1783
+ else {}
1784
+ )
1785
+
1786
+ if apply_res and not apply_res.get("ok", True):
1787
+ failed_details = "\n".join(
1788
+ [
1789
+ f"- Block {f.get('block_index', '?')}: {f.get('reason')}\n"
1790
+ f" SEARCH preview:\n```\n{f.get('search_preview')}\n```"
1791
+ for f in apply_res.get("failed", [])
1792
+ ]
1793
+ )
1794
+ msg = (
1795
+ f"PATCH APPLICATION REJECTED (Matched: "
1796
+ f"{apply_res.get('matched', 0)}):\n{failed_details}"
1797
+ )
1798
+ console.print(
1799
+ Panel(
1800
+ msg,
1801
+ title="Patch Application Failed",
1802
+ border_style="bold red",
1803
+ )
1804
+ )
1805
+
1806
+ target_path = os.path.join(
1807
+ self.proj["path"], resolved_target or ""
1808
+ )
1809
+ target_exists = bool(resolved_target) and os.path.exists(target_path)
1810
+ target_lines = 0
1811
+ if target_exists:
1812
+ try:
1813
+ with open(target_path, "r", encoding="utf-8") as tf:
1814
+ target_lines = sum(1 for _ in tf)
1815
+ except Exception:
1816
+ pass
1817
+
1818
+ state["editor_feedback"] = (
1819
+ f"CRITICAL PATCH ERROR: Your response could not be parsed.\n\n"
1820
+ f"What we found:\n"
1821
+ f" - Target file: {resolved_target} "
1822
+ f"({'exists' if target_exists else 'does not exist'}, "
1823
+ f"{target_lines} lines)\n"
1824
+ f" - Expected format: either SEARCH/REPLACE blocks, "
1825
+ f"OR a single full-file code block (>=30 lines) if the "
1826
+ f"whole file must be rewritten.\n\n"
1827
+ f"If your SEARCH block was marked AMBIGUOUS_MATCH, "
1828
+ f"include at least 3-5 surrounding lines so the anchor "
1829
+ f"is unique. A single-token anchor like `/*` will match "
1830
+ f"every comment in the file.\n\n"
1831
+ f"Preferred format for an existing file:\n"
1832
+ f"<<<<<<< SEARCH\n"
1833
+ f"(exact lines to find, copied verbatim from the file)\n"
1834
+ f"=======\n"
1835
+ f"(replacement lines)\n"
1836
+ f">>>>>>> REPLACE\n\n"
1837
+ f"Fallback format for a full rewrite:\n"
1838
+ f"```<language>\n(complete file content)\n```"
1839
+ )
1840
+
1841
+ state["edit_retries"] = state.get("edit_retries", 0) + 1
1842
+ # Increment iteration_count so MAX_ITERATIONS actually fires
1843
+ state["iteration_count"] = (
1844
+ state.get("iteration_count", 0) + 1
1845
+ )
1846
+
1847
+ if state["edit_retries"] >= 3:
1848
+ console.print(
1849
+ "[bold red]Max Edit retries reached (3). "
1850
+ "Reverting step to Research.[/bold red]"
1851
+ )
1852
+ # Clean up partial writes to this file if the
1853
+ # pipeline created it. This prevents the researcher
1854
+ # from seeing a broken file on the next cycle.
1855
+ if resolved_target and self._is_pipeline_file(
1856
+ state, resolved_target
1857
+ ):
1858
+ abs_target = os.path.join(
1859
+ self.proj["path"], resolved_target
1860
+ )
1861
+ try:
1862
+ if os.path.exists(abs_target):
1863
+ os.remove(abs_target)
1864
+ console.print(
1865
+ f"[dim]Removed broken pipeline-created "
1866
+ f"file: {resolved_target}[/dim]"
1867
+ )
1868
+ except OSError:
1869
+ pass
1870
+ self._untrack_pipeline_file(state, resolved_target)
1871
+
1872
+ state["current_step"] = "research"
1873
+ state["edit_retries"] = 0
1874
+ else:
1875
+ state["current_step"] = "edit"
1876
+
1877
+ self.save_state(state)
1878
+ continue
1879
+
1880
+ # ---- Extract the written code ----
1881
+ state["edit_retries"] = 0
1882
+
1883
+ research_data = state.get("research_data", {})
1884
+ res_file = (
1885
+ state.get("target_file")
1886
+ or research_data.get("target_file")
1887
+ or state.get("active_target_file")
1888
+ )
1889
+
1890
+ res_code = ""
1891
+ expected_target = state.get("target_file")
1892
+
1893
+ if isinstance(raw_generated, dict):
1894
+ res_code = (
1895
+ raw_generated.get("code")
1896
+ or raw_generated.get("content")
1897
+ or raw_generated.get("source")
1898
+ or ""
1899
+ )
1900
+ editor_target = raw_generated.get("target_file")
1901
+ if editor_target:
1902
+ if expected_target and editor_target != expected_target:
1903
+ console.print(
1904
+ f"[bold yellow]WARNING: Editor tried to write '{editor_target}' "
1905
+ f"instead of plan target '{expected_target}'. Forcing plan target.[/bold yellow]"
1906
+ )
1907
+ else:
1908
+ res_file = editor_target
1909
+ elif isinstance(raw_generated, str):
1910
+ try:
1911
+ parsed_json = extract_json_payload(raw_generated)
1912
+ if isinstance(parsed_json, dict):
1913
+ res_code = (
1914
+ parsed_json.get("code")
1915
+ or parsed_json.get("content")
1916
+ or parsed_json.get("source")
1917
+ or ""
1918
+ )
1919
+ editor_target = parsed_json.get("target_file")
1920
+ if editor_target:
1921
+ if expected_target and editor_target != expected_target:
1922
+ console.print(
1923
+ f"[bold yellow]WARNING: Editor tried to write '{editor_target}' "
1924
+ f"instead of plan target '{expected_target}'. Forcing plan target.[/bold yellow]"
1925
+ )
1926
+ else:
1927
+ res_file = editor_target
1928
+ else:
1929
+ res_code = raw_generated
1930
+ except Exception:
1931
+ fence_match = re.search(
1932
+ r"```(?:\w+)?\n(.*?)```", raw_generated, re.DOTALL
1933
+ )
1934
+ res_code = (
1935
+ fence_match.group(1) if fence_match else raw_generated
1936
+ )
1937
+
1938
+ if not res_file:
1939
+ console.print(
1940
+ "[bold red]Editor Error:[/bold red] No target file specified."
1941
+ )
1942
+ state["current_step"] = "edit"
1943
+ self.save_state(state)
1944
+ continue
1945
+
1946
+ try:
1947
+ res_code = sanitize_generated_code(res_code, allow_patches=True)
1948
+ except ValueError as e:
1949
+ console.print(f"[bold red]Editor Error: {str(e)}[/bold red]")
1950
+ state["editor_feedback"] = f"ERROR: {str(e)}"
1951
+ state["current_step"] = "edit"
1952
+ self.save_state(state)
1953
+ continue
1954
+
1955
+ # ---- Security Check ----
1956
+ allow_cleanup = getattr(config, "RSI_MODE", False)
1957
+ danger_hits = find_dangerous_code(res_code, allow_cleanup=allow_cleanup)
1958
+ if danger_hits:
1959
+ msg = f"Forbidden patterns detected: {', '.join(danger_hits)}"
1960
+ console.print(
1961
+ Panel(
1962
+ msg,
1963
+ title="Security Warning",
1964
+ border_style="bold red",
1965
+ )
1966
+ )
1967
+ state["compiler_output"] = f"SECURITY_ERROR: Edit rejected. {msg}"
1968
+ state["current_step"] = "edit"
1969
+ self.save_state(state)
1970
+ continue
1971
+
1972
+ # ---- Backup old content ----
1973
+ old_code = self.sandbox.safe_read_file(res_file)
1974
+ old_code_backup = (
1975
+ ""
1976
+ if (
1977
+ isinstance(old_code, str)
1978
+ and old_code.startswith("Error")
1979
+ )
1980
+ else str(old_code)
1981
+ )
1982
+ state["old_code_backup"] = old_code_backup
1983
+
1984
+ # ---- Contract validation ----
1985
+ if self.validator is not None:
1986
+ change_set = ChangeSet()
1987
+ change_set.add_write(res_file, res_code)
1988
+ with console.status(
1989
+ "[cyan]Validating against structure index...",
1990
+ spinner="dots",
1991
+ ):
1992
+ report = self.validator.validate_change_set(change_set)
1993
+
1994
+ if report.internal_error:
1995
+ console.print(
1996
+ f"[dim yellow]Validator internal error (failing open): "
1997
+ f"{report.internal_error}[/dim yellow]"
1998
+ )
1999
+ elif report.errors:
2000
+ failed_lines = "\n".join(
2001
+ f" {e.path}:{e.line}: [{e.code}] {e.message}"
2002
+ for e in report.errors
2003
+ )
2004
+ console.print(
2005
+ Panel(
2006
+ f"[bold red]CONTRACT VALIDATION FAILED[/bold red]\n"
2007
+ f"{failed_lines}",
2008
+ border_style="bold red",
2009
+ )
2010
+ )
2011
+ state["editor_feedback"] = (
2012
+ "CONTRACT VALIDATION FAILED — your proposed code calls "
2013
+ "names that do not exist in the current codebase:\n"
2014
+ f"{failed_lines}\n\n"
2015
+ "INSTRUCTION: Provide corrections that only reference "
2016
+ "modules, classes, and functions that actually exist. "
2017
+ "If the target symbol must be created first, add it in "
2018
+ "the same patch."
2019
+ )
2020
+ state["edit_retries"] = state.get("edit_retries", 0) + 1
2021
+ state["iteration_count"] = (
2022
+ state.get("iteration_count", 0) + 1
2023
+ )
2024
+ if state["edit_retries"] >= 3:
2025
+ console.print(
2026
+ "[bold red]Max Edit retries reached (3). "
2027
+ "Reverting step to Research.[/bold red]"
2028
+ )
2029
+ state["current_step"] = "research"
2030
+ state["edit_retries"] = 0
2031
+ else:
2032
+ state["current_step"] = "edit"
2033
+ self.save_state(state)
2034
+ continue
2035
+
2036
+ # --- Pre-flight syntax check ---
2037
+ if res_file.endswith(".py"):
2038
+ try:
2039
+ compile(res_code, res_file, "exec")
2040
+ except SyntaxError as syn_err:
2041
+ console.print(f"[bold red]SYNTAX ERROR in generated code: {syn_err}[/bold red]")
2042
+ state["editor_feedback"] = f"SYNTAX ERROR: {syn_err}. Please fix the syntax."
2043
+ state["current_step"] = "edit"
2044
+ state["iteration_count"] = state.get("iteration_count", 0) + 1
2045
+ self.save_state(state)
2046
+ continue
2047
+
2048
+ # ---- Atomic apply ----
2049
+ change_set = ChangeSet()
2050
+ change_set.add_write(res_file, res_code)
2051
+ # VERIFICATION FIREWALL: verify the file actually changed on disk
2052
+ from tools.security.verification_firewall import (
2053
+ fs_hash, verify_file_changed,
2054
+ )
2055
+
2056
+ abs_target = os.path.join(self.proj["path"], res_file)
2057
+ pre_apply_hash = fs_hash(abs_target)
2058
+ # --- RSI Critical File Gate (Phase 1.3) ---
2059
+ is_rsi = getattr(config, "RSI_MODE", False)
2060
+ is_critical = res_file in getattr(config, "RSI_CRITICAL_FILES", set())
2061
+
2062
+ if is_rsi and is_critical:
2063
+ pending_dir = os.path.join(self.proj["path"], ".vegex", "pending_critical_changes")
2064
+ os.makedirs(pending_dir, exist_ok=True)
2065
+ safe_filename = res_file.replace("/", "_").replace("\\", "_")
2066
+ pending_file = os.path.join(pending_dir, f"{safe_filename}_{int(time.time())}.patch")
2067
+
2068
+ # Save the proposed change to disk for review
2069
+ with open(pending_file, "w", encoding="utf-8") as f:
2070
+ f.write(res_code)
2071
+
2072
+ console.print(Panel(
2073
+ f"[bold yellow]RSI CRITICAL FILE GATE TRIGGERED[/bold yellow]\n\n"
2074
+ f"Agent attempted to modify core framework file: [cyan]{res_file}[/cyan]\n"
2075
+ f"Change saved to: [dim]{pending_file}[/dim]\n\n"
2076
+ f"Manual review required. The pipeline will skip applying this file\n"
2077
+ f"and move to the next step.",
2078
+ border_style="yellow",
2079
+ ))
2080
+
2081
+ state["editor_feedback"] = (
2082
+ f"CHANGE GATED: Modifications to '{res_file}' require human approval. "
2083
+ f"Your code was saved to pending review, but not applied to the live file. "
2084
+ f"Proceed with your next subtask or finish."
2085
+ )
2086
+
2087
+ # Mark step completed so the pipeline doesn't loop endlessly
2088
+ self._mark_step_completed(state, res_file)
2089
+
2090
+ # Check if there are more pending steps
2091
+ pending = [s for s in state.get("plan", []) if s.get("status") == "pending"]
2092
+ if pending:
2093
+ state["current_step"] = "research"
2094
+ else:
2095
+ state["current_step"] = "done"
2096
+ state["status"] = "completed"
2097
+
2098
+ self.save_state(state)
2099
+ continue # Skip actual application
2100
+ # ------------------------------------------
2101
+
2102
+ # ---- Atomic apply ----
2103
+ change_set = ChangeSet()
2104
+ change_set.add_write(res_file, res_code)
2105
+ apply_report = change_set.apply(self.proj["path"])
2106
+
2107
+ if apply_report.ok:
2108
+ changed, post_hash = verify_file_changed(abs_target, pre_apply_hash)
2109
+ if not changed:
2110
+ console.print(
2111
+ f"[bold red]⚠ SECURITY: apply_report.ok=True but file "
2112
+ f"did not change on disk. Trusting fs over agent.[/bold red]"
2113
+ )
2114
+ state["editor_feedback"] = (
2115
+ "Filesystem verification failed: the file did not "
2116
+ "change after apply. The atomic changeset may be corrupted."
2117
+ )
2118
+ state["current_step"] = "edit"
2119
+ self.save_state(state)
2120
+ continue
2121
+
2122
+ if not apply_report.ok:
2123
+ console.print(
2124
+ Panel(
2125
+ f"[bold red]ATOMIC APPLY FAILED[/bold red]\n"
2126
+ f"{apply_report.error}",
2127
+ border_style="bold red",
2128
+ )
2129
+ )
2130
+ state["editor_feedback"] = (
2131
+ f"Filesystem apply failed: {apply_report.error}"
2132
+ )
2133
+ state["current_step"] = "edit"
2134
+ self.save_state(state)
2135
+ continue
2136
+
2137
+ current_diff = self.logger.generate_diff(
2138
+ old_code_backup, res_code, res_file
2139
+ )
2140
+ console.print(
2141
+ f"[green]Applied {len(apply_report.applied)} file(s) atomically "
2142
+ f"in {apply_report.elapsed_ms:.1f}ms[/green]"
2143
+ )
2144
+
2145
+ is_framework_root = os.path.abspath(self.proj["path"]) == os.path.abspath(self.VEGEX_FRAMEWORK_ROOT)
2146
+ if config.RSI_MODE and is_framework_root:
2147
+ missing = self._check_critical_files_intact()
2148
+ if missing:
2149
+ console.print(Panel(
2150
+ f"[bold red]CRITICAL FILES MISSING AFTER APPLY[/bold red]\n"
2151
+ f"{chr(10).join(missing)}\n\n"
2152
+ f"Rolling back the change set.",
2153
+ border_style="bold red",
2154
+ ))
2155
+ # Revert the file we just wrote
2156
+ if not old_code_backup.strip():
2157
+ abs_path = os.path.join(self.proj["path"], res_file)
2158
+ try:
2159
+ os.remove(abs_path)
2160
+ except OSError:
2161
+ pass
2162
+ else:
2163
+ self.sandbox.safe_write_code(res_file, old_code_backup)
2164
+
2165
+ state["editor_feedback"] = (
2166
+ f"REJECTED: your change would have deleted critical framework "
2167
+ f"files: {', '.join(missing)}. Rollback applied. Do not touch "
2168
+ f"files outside the target module."
2169
+ )
2170
+ state["current_step"] = "edit"
2171
+ self.save_state(state)
2172
+ continue
2173
+
2174
+ # Track pipeline-created files: if backup is empty, the file
2175
+ # did not exist before this write.
2176
+ if not old_code_backup.strip():
2177
+ self._track_pipeline_file(state, res_file)
2178
+
2179
+ if self._mark_step_completed(state, res_file):
2180
+ console.print(
2181
+ f"[dim]Marked plan step for '{res_file}' as completed.[/dim]"
2182
+ )
2183
+
2184
+ state["active_target_file"] = res_file
2185
+ state["target_file"] = res_file
2186
+ state["current_step"] = "review"
2187
+ self.save_state(state)
2188
+
2189
+ # ============================================================
2190
+ # STEP 3: VERIFICATION & REVIEW
2191
+ # ============================================================
2192
+ elif step == "review":
2193
+ console.rule(
2194
+ "[bold magenta]Step 3: Verification and Review[/bold magenta]"
2195
+ )
2196
+ if not res_file:
2197
+ res_file = state.get("active_target_file")
2198
+
2199
+ backup_code = state.get("old_code_backup", old_code_backup)
2200
+
2201
+ with console.status(
2202
+ "[magenta]Running compilation/test checks...", spinner="dots"
2203
+ ):
2204
+ build_success, log = run_universal_build(
2205
+ self.proj["path"],
2206
+ self.proj.get("lang", "python"),
2207
+ target_file=res_file,
2208
+ )
2209
+
2210
+ # VERIFICATION FIREWALL: build_success from run_universal_build
2211
+ # is the ONLY authoritative source. Do not trust state for this.
2212
+ from tools.security.verification_firewall import verify_build_result
2213
+
2214
+ claimed_success = bool(state.get("build_success_claimed"))
2215
+ verification = verify_build_result(
2216
+ claimed_success=claimed_success,
2217
+ actual_success=build_success,
2218
+ actual_log=log,
2219
+ )
2220
+
2221
+ if not verification["consistent"]:
2222
+ for warning in verification["warnings"]:
2223
+ console.print(f"[bold red]⚠ SECURITY: {warning}[/bold red]")
2224
+ # Inconsistency is suspicious but not fatal — we use trusted_success
2225
+ # (the compiler's answer) and log the discrepancy.
2226
+
2227
+ state["compiler_output"] = log # always compiler, never agent
2228
+ state["build_success_claimed"] = None # clear after verify
2229
+
2230
+ # ---- Coherence gating ----
2231
+ if self._coherence_should_run(state):
2232
+ coherence_ctx = self._build_coherence_report(state)
2233
+ else:
2234
+ pending_remaining = [
2235
+ s.get("target_file")
2236
+ for s in state.get("plan", [])
2237
+ if isinstance(s, dict) and s.get("status") == "pending"
2238
+ ]
2239
+ coherence_ctx = ""
2240
+ console.print(
2241
+ f"[dim]Coherence check deferred — "
2242
+ f"{len(pending_remaining)} step(s) still pending: "
2243
+ f"{', '.join(str(p) for p in pending_remaining[:3])}"
2244
+ f"{'...' if len(pending_remaining) > 3 else ''}[/dim]"
2245
+ )
2246
+
2247
+ # Coherence gate: if the report has errors, do not hand the diff to
2248
+ # the reviewer.
2249
+ if coherence_ctx and "error" in coherence_ctx.lower():
2250
+ rerouted = False
2251
+ for step in reversed(state.get("plan", [])):
2252
+ if (
2253
+ isinstance(step, dict)
2254
+ and step.get("target_file", "").endswith((".css", ".html"))
2255
+ and step.get("status") == "completed"
2256
+ ):
2257
+ step["status"] = "pending"
2258
+ state["target_file"] = step["target_file"]
2259
+ state["active_subtask"] = step.get("title")
2260
+ state["current_step"] = "edit"
2261
+ state["editor_feedback"] = (
2262
+ "COHERENCE / SYNTAX FAILURE — the pipeline found "
2263
+ "errors in your code. Fix ONLY what is mentioned below.\n\n"
2264
+ f"--- COMPILER LOG ---\n{log}\n\n"
2265
+ f"--- COHERENCE REPORT ---\n{coherence_ctx}"
2266
+ )
2267
+ console.print(
2268
+ f"[yellow]Coherence failed — rerouting to "
2269
+ f"{step.get('target_file')} to fix consistency[/yellow]"
2270
+ )
2271
+ rerouted = True
2272
+ break
2273
+
2274
+ if rerouted:
2275
+ if res_file:
2276
+ self._revert_file(state, res_file, backup_code)
2277
+ self._mark_step_pending(state, res_file)
2278
+
2279
+ state["active_lesson_node_ids"] = []
2280
+ state.pop("active_target_file", None)
2281
+ state.pop("generated_code", None)
2282
+ state.pop("old_code_backup", None)
2283
+ self.save_state(state)
2284
+ continue
2285
+
2286
+ # --- Visual Review Integration ---
2287
+ visual_ctx = ""
2288
+ if res_file and res_file.endswith(".html"):
2289
+ ref_path = os.path.join(self.proj["path"], "reference.png")
2290
+ target_abs = os.path.join(self.proj["path"], res_file)
2291
+
2292
+ if os.path.exists(ref_path) and os.path.exists(target_abs):
2293
+ with console.status("[magenta]Running visual diff against reference...", spinner="dots"):
2294
+ try:
2295
+ vis_res = visual_review_page(
2296
+ html_path=target_abs,
2297
+ reference_png=ref_path,
2298
+ output_dir=os.path.join(self.proj["path"], ".vegex", "diffs")
2299
+ )
2300
+ if vis_res and not vis_res.get("ok", True):
2301
+ visual_ctx = vis_res.get("feedback", "")
2302
+ console.print(f"[bold yellow]Visual Diff failed (SSIM: {vis_res.get('ssim', 0):.2f})[/bold yellow]")
2303
+ elif vis_res and vis_res.get("ssim"):
2304
+ console.print(f"[dim green]Visual Diff passed (SSIM: {vis_res['ssim']:.2f})[/dim green]")
2305
+ except Exception as vis_exc:
2306
+ console.print(f"[dim yellow]Visual review skipped: {vis_exc}[/dim yellow]")
2307
+ # ---------------------------------
2308
+
2309
+ # ---- Reviewer ----
2310
+ session_ctx = self._build_session_context(
2311
+ state, role="reviewer", record_trigger=True
2312
+ )
2313
+
2314
+ with console.status(
2315
+ "[magenta]Reviewer auditing code diff...", spinner="dots"
2316
+ ):
2317
+ state, meta = run_reviewer(
2318
+ state,
2319
+ project_dir=self.proj["path"],
2320
+ session_context=session_ctx,
2321
+ coherence_report=coherence_ctx,
2322
+ visual_context=visual_ctx
2323
+ )
2324
+ self.update_costs(meta, agent_name="reviewer")
2325
+
2326
+ rev_res = state.get("review_result", {})
2327
+ verdict = str(rev_res.get("verdict", "REJECT")).upper()
2328
+ reason = (
2329
+ rev_res.get("reason")
2330
+ or rev_res.get("feedback")
2331
+ or "No detailed reason provided."
2332
+ )
2333
+
2334
+ # ---- Lesson feedback ----
2335
+ injected_ids = state.get("active_lesson_node_ids") or []
2336
+ helpful_ids: list = []
2337
+ violated_ids: list = []
2338
+ if injected_ids:
2339
+ try:
2340
+ helpful_ids, violated_ids = analyze_lesson_outcome(
2341
+ build_success=build_success,
2342
+ build_log=log,
2343
+ injected_node_ids=injected_ids,
2344
+ )
2345
+ except Exception as fb_exc:
2346
+ console.print(
2347
+ f"[dim yellow]Lesson analysis failed: {fb_exc}[/dim yellow]"
2348
+ )
2349
+
2350
+ # ====================================================
2351
+ # ACCEPT
2352
+ # ====================================================
2353
+ if build_success and verdict == "ACCEPT":
2354
+ console.print(
2355
+ Panel(
2356
+ "Build passed and Reviewer accepted changes.",
2357
+ border_style="bold green",
2358
+ )
2359
+ )
2360
+
2361
+ if helpful_ids:
2362
+ try:
2363
+ n = record_lesson_helpful(helpful_ids)
2364
+ console.print(
2365
+ f"[dim]Lesson feedback: {n} lesson(s) marked "
2366
+ f"helpful (H+1)[/dim]"
2367
+ )
2368
+ except Exception as fb_exc:
2369
+ console.print(
2370
+ f"[dim yellow]record_helpful failed: "
2371
+ f"{fb_exc}[/dim yellow]"
2372
+ )
2373
+ state["active_lesson_node_ids"] = []
2374
+
2375
+ if self.adr is not None:
2376
+ try:
2377
+ sid = (
2378
+ (state.get("meta") or {}).get("session_id")
2379
+ or "unknown"
2380
+ )
2381
+ entry = build_adr_entry(
2382
+ session_id=sid,
2383
+ cycle=state.get("iteration_count", 0),
2384
+ action="edit",
2385
+ outcome="accepted",
2386
+ files_touched=[res_file] if res_file else [],
2387
+ decisions=[reason[:200]] if reason else [],
2388
+ )
2389
+ self.adr.append(entry)
2390
+ except Exception as adr_exc:
2391
+ console.print(
2392
+ f"[dim yellow]ADR append failed: "
2393
+ f"{adr_exc}[/dim yellow]"
2394
+ )
2395
+
2396
+ plan = state.get("plan", [])
2397
+ pending_steps = [
2398
+ s
2399
+ for s in plan
2400
+ if isinstance(s, dict) and s.get("status") == "pending"
2401
+ ]
2402
+
2403
+ if pending_steps:
2404
+ next_step = pending_steps[0]
2405
+ state["active_subtask"] = next_step.get("title")
2406
+ state["target_file"] = next_step.get("target_file")
2407
+ state["current_step"] = "research"
2408
+ console.print(
2409
+ f"[green]Advancing to next step:[/green] "
2410
+ f"{next_step.get('title')}"
2411
+ )
2412
+ else:
2413
+ state["active_subtask"] = None
2414
+ state["target_file"] = None
2415
+ state["current_step"] = "done"
2416
+ state["status"] = "completed"
2417
+ console.print(
2418
+ "[bold green]All plan steps completed.[/bold green]"
2419
+ )
2420
+
2421
+ state.pop("active_target_file", None)
2422
+ state.pop("generated_code", None)
2423
+ state.pop("old_code_backup", None)
2424
+ state["recent_rejections"] = []
2425
+
2426
+ state["iteration_count"] = state.get("iteration_count", 0) + 1
2427
+ self.save_state(state)
2428
+ self.logger.record_iteration(state, diff_data=current_diff)
2429
+
2430
+ # ====================================================
2431
+ # REJECT
2432
+ # ====================================================
2433
+ else:
2434
+ console.print(
2435
+ Panel(
2436
+ f"[bold red]REJECTED[/bold red]\n"
2437
+ f"[yellow]Reason:[/yellow] {reason}",
2438
+ border_style="bold red",
2439
+ )
2440
+ )
2441
+
2442
+ if self._check_rejection_loop(state, reason):
2443
+ console.print(
2444
+ Panel(
2445
+ "[bold red]CIRCUIT BREAKER TRIGGERED[/bold red]\n"
2446
+ "The same rejection reason has appeared 3 times "
2447
+ "in a row. The pipeline is stuck in a loop.\n\n"
2448
+ "Pausing execution. Inspect state.json, fix any "
2449
+ "underlying issue, then run again.",
2450
+ border_style="bold red",
2451
+ )
2452
+ )
2453
+ state["current_step"] = "done"
2454
+ state["status"] = "failed"
2455
+ self.save_state(state)
2456
+ break
2457
+
2458
+ if violated_ids:
2459
+ try:
2460
+ n = record_lesson_violation(violated_ids)
2461
+ console.print(
2462
+ f"[dim yellow]Lesson feedback: {n} lesson(s) "
2463
+ f"marked as violated (V+1) — the injected rules "
2464
+ f"did not prevent the failure[/dim yellow]"
2465
+ )
2466
+ except Exception as fb_exc:
2467
+ console.print(
2468
+ f"[dim yellow]record_violation failed: "
2469
+ f"{fb_exc}[/dim yellow]"
2470
+ )
2471
+
2472
+ if self.adr is not None:
2473
+ try:
2474
+ sid = (
2475
+ (state.get("meta") or {}).get("session_id")
2476
+ or "unknown"
2477
+ )
2478
+ entry = build_adr_entry(
2479
+ session_id=sid,
2480
+ cycle=state.get("iteration_count", 0),
2481
+ action="edit",
2482
+ outcome="rejected",
2483
+ files_touched=[res_file] if res_file else [],
2484
+ decisions=[reason[:200]] if reason else [],
2485
+ )
2486
+ self.adr.append(entry)
2487
+ except Exception as adr_exc:
2488
+ console.print(
2489
+ f"[dim yellow]ADR append failed: "
2490
+ f"{adr_exc}[/dim yellow]"
2491
+ )
2492
+
2493
+ # Revert via helper (handles empty-backup case by deleting)
2494
+ if res_file:
2495
+ self._revert_file(state, res_file, backup_code)
2496
+ self._mark_step_pending(state, res_file)
2497
+
2498
+ state["compiler_output"] = (
2499
+ f"REVIEWER_REJECTED: {reason}\nBuild output: {log[:300]}"
2500
+ )
2501
+ state["editor_feedback"] = (
2502
+ f"REJECTED BY REVIEWER:\nReason: {reason}"
2503
+ )
2504
+ state["current_step"] = "edit"
2505
+
2506
+ state.pop("generated_code", None)
2507
+ state.pop("active_target_file", None)
2508
+ state.pop("old_code_backup", None)
2509
+ res_file = None
2510
+
2511
+ state["iteration_count"] = state.get("iteration_count", 0) + 1
2512
+ self.save_state(state)
2513
+ self.logger.record_iteration(state, diff_data=current_diff)
2514
+ elif step == "reflect":
2515
+ console.rule("[bold blue]Step 0: Self-Reflection[/bold blue]")
2516
+
2517
+ from tools.reflection import run_reflection
2518
+
2519
+ with console.status(
2520
+ "[blue]Analyzing recent failures for patterns...", spinner="dots"
2521
+ ):
2522
+ reflect_result = run_reflection(state, project_dir=self.proj["path"])
2523
+
2524
+ if reflect_result.observations_written:
2525
+ console.print(Panel(
2526
+ f"[bold green]Reflection produced "
2527
+ f"{len(reflect_result.observations_written)} new observation(s):[/bold green]\n"
2528
+ + "\n".join(f" • {oid}" for oid in reflect_result.observations_written)
2529
+ + (
2530
+ f"\n\n[dim]Skipped (already exist): "
2531
+ f"{len(reflect_result.observations_skipped)}[/dim]"
2532
+ if reflect_result.observations_skipped else ""
2533
+ ),
2534
+ border_style="blue",
2535
+ title="Self-Reflection",
2536
+ ))
2537
+ else:
2538
+ console.print(
2539
+ f"[dim]Reflection: no new observations "
2540
+ f"({reflect_result.failures_analyzed} failures analyzed, "
2541
+ f"{reflect_result.patterns_found} patterns found)[/dim]"
2542
+ )
2543
+
2544
+ # Track that reflection ran
2545
+ state["metrics"] = state.get("metrics", {})
2546
+ state["metrics"]["last_reflection"] = {
2547
+ "iteration": iteration,
2548
+ "observations_written": reflect_result.observations_written,
2549
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2550
+ }
2551
+
2552
+ # Re-sync lessons into SQLite so new observations become rankable
2553
+ try:
2554
+ from tools.lesson_memory_bridge import LessonMemoryBridge
2555
+ bridge = LessonMemoryBridge(
2556
+ lessons_root=os.path.join(self.proj["path"], "lessons")
2557
+ )
2558
+ bridge.sync_all()
2559
+ except Exception as exc:
2560
+ console.print(
2561
+ f"[dim yellow]Bridge sync after reflection failed: {exc}[/dim yellow]"
2562
+ )
2563
+
2564
+ # Return to whatever step we came from
2565
+ previous = state.pop("_pre_reflect_step", "research")
2566
+ state["current_step"] = previous
2567
+ self.save_state(state)
2568
+ continue
2569
+ # ============================================================
2570
+ # STEP 4: DONE
2571
+ # ============================================================
2572
+ elif step in ("done", "completed"):
2573
+ console.print(
2574
+ Panel(
2575
+ "[bold green]All tasks completed![/bold green]",
2576
+ border_style="green",
2577
+ )
2578
+ )
2579
+ break
2580
+
2581
+ else:
2582
+ console.print(
2583
+ f"[bold red]Unknown step '{step}'. Defaulting to "
2584
+ f"'research'...[/bold red]"
2585
+ )
2586
+ state["current_step"] = "research"
2587
+ self.save_state(state)
2588
+ time.sleep(1)
2589
+
2590
+ # ------------------------------------------------------------------ #
2591
+ # Main loop
2592
+ # ------------------------------------------------------------------ #
2593
+ def start(self):
2594
+ console.clear()
2595
+ console.print(
2596
+ Panel(
2597
+ "[bold cyan]VegeX Autonomous Multi-Agent Orchestrator[/bold cyan]\n"
2598
+ "[dim]Interactive Terminal Interface[/dim]",
2599
+ border_style="cyan",
2600
+ expand=False,
2601
+ )
2602
+ )
2603
+
2604
+ if self.proj:
2605
+ console.print(
2606
+ f"[bold green]Active Workspace:[/bold green] {self.proj['name']} "
2607
+ f"([cyan]{self.proj['path']}[/cyan])\n"
2608
+ )
2609
+ console.print(f"[dim]Framework root: {self.VEGEX_FRAMEWORK_ROOT}[/dim]")
2610
+ console.print(f"[dim]Registry: {REGISTRY_FILE}[/dim]\n")
2611
+ else:
2612
+ console.print(
2613
+ "[bold yellow]No active workspace selected. "
2614
+ "Use 'cd <path>' or 'projects' to pick one.[/bold yellow]\n"
2615
+ )
2616
+
2617
+ while True:
2618
+ try:
2619
+ cmd_line = console.input("[bold cyan]VegeX > [/bold cyan]").strip()
2620
+
2621
+ if _looks_like_paste_artifact(cmd_line):
2622
+ continue
2623
+
2624
+ if not cmd_line:
2625
+ continue
2626
+
2627
+ parts = cmd_line.split(maxsplit=1)
2628
+ head = parts[0].lower()
2629
+ sub_args = parts[1] if len(parts) > 1 else ""
2630
+
2631
+ if head in ("task", "t"):
2632
+ self.handle_task_command(sub_args)
2633
+
2634
+ elif head in ("run", "start"):
2635
+ self.run_agent_cycle(prompt_arg=sub_args)
2636
+
2637
+ elif head == "cd":
2638
+ self.change_directory(sub_args)
2639
+
2640
+ elif head in ("projects", "ls"):
2641
+ self.print_projects_table()
2642
+
2643
+ elif head == "llm":
2644
+ if sub_args.startswith("routing "):
2645
+ from tools.llm_selector import set_dynamic_routing_enabled
2646
+ mode = sub_args.split(maxsplit=1)[1].strip().lower()
2647
+ if mode == "on":
2648
+ set_dynamic_routing_enabled(True)
2649
+ console.print("[bold green]Dynamic LLM routing ENABLED. Models will be picked by complexity.[/bold green]")
2650
+ elif mode == "off":
2651
+ set_dynamic_routing_enabled(False)
2652
+ console.print("[bold yellow]Dynamic LLM routing DISABLED. Manual bindings will be used.[/bold yellow]")
2653
+ else:
2654
+ console.print("[bold red]Usage: llm routing [on|off][/bold red]")
2655
+ else:
2656
+ handle_llm_command(sub_args)
2657
+
2658
+ elif head == "msg":
2659
+ project_dir = self.proj["path"] if self.proj else "."
2660
+ handle_msg_command(
2661
+ project_dir=project_dir, state=self.load_state()
2662
+ )
2663
+
2664
+ elif head == "handshake":
2665
+ self.handle_handshake_command(sub_args)
2666
+
2667
+ elif head == "coherence":
2668
+ self.handle_coherence_command(sub_args)
2669
+
2670
+ elif head == "plugin":
2671
+ self.handle_plugin_command(sub_args)
2672
+
2673
+ elif head == "reflect":
2674
+ if not self.proj or not os.path.isdir(self.proj["path"]):
2675
+ console.print("[bold red]Error:[/bold red] No valid workspace selected.")
2676
+ continue
2677
+ state = self.load_state()
2678
+ from tools.reflection import run_reflection
2679
+ with console.status("[blue]Analyzing recent failures...", spinner="dots"):
2680
+ res = run_reflection(state, project_dir=self.proj["path"], force=True)
2681
+ console.print(Panel(
2682
+ json.dumps(res.as_dict(), indent=2),
2683
+ title="Reflection Result",
2684
+ border_style="blue",
2685
+ ))
2686
+
2687
+ elif head == "status":
2688
+ self.show_status()
2689
+
2690
+ elif head in ("log", "changelog"):
2691
+ self.show_changelog()
2692
+
2693
+ elif head == "lesson":
2694
+ self.handle_lesson_command(sub_args)
2695
+
2696
+ elif head == "pause":
2697
+ self.should_pause = True
2698
+ console.print("[yellow]Pause flag set.[/yellow]")
2699
+
2700
+ elif head == "stop":
2701
+ self.should_stop = True
2702
+ console.print("[red]Stop flag set.[/red]")
2703
+
2704
+ elif head == "history":
2705
+ if self.proj:
2706
+ show_all_commits(self.proj["path"])
2707
+ else:
2708
+ console.print(
2709
+ "[bold red]No active project selected.[/bold red]"
2710
+ )
2711
+
2712
+ elif head == "rollback":
2713
+ if not sub_args:
2714
+ console.print(
2715
+ "[bold yellow]Usage:[/bold yellow] rollback <commit_sha>"
2716
+ )
2717
+ elif self.proj:
2718
+ safe_rollback(self.proj["path"], sub_args)
2719
+
2720
+ elif head == "help":
2721
+ self.print_help()
2722
+
2723
+ elif head == "upd-tiers":
2724
+ from tools.model_ranker import auto_update_tiers
2725
+ auto_update_tiers()
2726
+
2727
+ elif head in ("exit", "quit"):
2728
+ console.print("[bold yellow]Exiting VegeX...[/bold yellow]")
2729
+ sys.exit(0)
2730
+
2731
+ else:
2732
+ console.print(
2733
+ f"[bold red]Unknown command:[/bold red] '{head}'. "
2734
+ f"Type [yellow]help[/yellow] for options."
2735
+ )
2736
+
2737
+ except (KeyboardInterrupt, EOFError):
2738
+ console.print("\nExiting VegeX...")
2739
+ break
2740
+
2741
+
2742
+ # ---------------------------------------------------------------------------
2743
+ # Console-script entry point
2744
+ # ---------------------------------------------------------------------------
2745
+ import sys
2746
+
2747
+ def run_cli() -> None:
2748
+ # Если аргументов нет, просто запускаем интерактивную консоль
2749
+ if len(sys.argv) <= 1:
2750
+ app = OrchestratorConsole()
2751
+ # Инициализируем guard прямо у экземпляра класса, если это необходимо
2752
+ app.behavior_guard = BehaviorGuard()
2753
+ app.start()
2754
+ return
2755
+
2756
+ cmd = sys.argv[1].lower()
2757
+ args = " ".join(sys.argv[2:])
2758
+
2759
+ # Карта соответствия команд и вызываемых функций/методов
2760
+ # Для методов создаем один экземпляр OrchestratorConsole()
2761
+ console = OrchestratorConsole()
2762
+
2763
+ commands = {
2764
+ ("--help", "-h", "help"): lambda: console.print_help(),
2765
+ ("task", "t", "--task", "-t"): lambda: console.handle_task_command(args),
2766
+ ("run", "start", "--run"): lambda: console.run_agent_cycle(prompt_arg=args),
2767
+ ("status", "--status"): lambda: console.show_status(),
2768
+ ("projects", "ls", "--projects"): lambda: console.print_projects_table(),
2769
+ ("log", "changelog", "--log"): lambda: console.show_changelog(),
2770
+ ("lesson",): lambda: console.handle_lesson_command(args),
2771
+ ("coherence",): lambda: console.handle_coherence_command(args),
2772
+ }
2773
+
2774
+ # Поиск и выполнение обычной команды
2775
+ for keys, func in commands.items():
2776
+ if cmd in keys:
2777
+ func()
2778
+ return
2779
+
2780
+ # Отдельная обработка для функций, требующих кастомной логики
2781
+ if cmd == "llm":
2782
+ handle_llm_command(args)
2783
+ return
2784
+
2785
+ if cmd == "msg":
2786
+ handle_msg_command(
2787
+ project_dir=console.proj["path"] if console.proj else ".",
2788
+ state=console.load_state(),
2789
+ )
2790
+ return
2791
+
2792
+ # Если команда не распознана
2793
+ print(f"Неизвестная команда: {cmd}. Используйте --help для справки.")
2794
+
2795
+ if __name__ == "__main__":
2796
+ run_cli()