hanuscode 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/permissions.py ADDED
@@ -0,0 +1,182 @@
1
+ # permissions.py
2
+ """
3
+ Sistema de permisos: default, plan, bypass.
4
+ Gestiona aprobaciones, denegaciones, auditoría y detección de loops.
5
+ """
6
+ from __future__ import annotations
7
+ import time
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Callable
13
+
14
+
15
+ class PermissionMode(Enum):
16
+ DEFAULT = "default" # Pide confirmación para acciones peligrosas
17
+ PLAN = "plan" # Aprueba el plan; ejecuta automáticamente
18
+ BYPASS = "bypass" # Sin restricciones (todo auto, excepto comandos absolutamente destructivos)
19
+
20
+
21
+ class RiskLevel(Enum):
22
+ LOW = "low"
23
+ MEDIUM = "medium"
24
+ HIGH = "high"
25
+
26
+
27
+ # ── Clasificación de riesgo por herramienta ───────────────────────────────────
28
+ TOOL_RISK: Dict[str, RiskLevel] = {
29
+ "read_file": RiskLevel.LOW,
30
+ "glob_search": RiskLevel.LOW,
31
+ "grep_search": RiskLevel.LOW,
32
+ "web_fetch": RiskLevel.LOW,
33
+ "web_search": RiskLevel.LOW,
34
+ "list_files": RiskLevel.LOW,
35
+ "git_status": RiskLevel.LOW,
36
+ "git_diff": RiskLevel.LOW,
37
+ "append_to_file": RiskLevel.LOW,
38
+ "create_file": RiskLevel.MEDIUM,
39
+ "file_edit": RiskLevel.MEDIUM,
40
+ "exec_file": RiskLevel.MEDIUM,
41
+ "exec_cmd": RiskLevel.MEDIUM,
42
+ "write_file": RiskLevel.HIGH,
43
+ "git_push": RiskLevel.HIGH,
44
+ "git_reset": RiskLevel.HIGH,
45
+ "subagent": RiskLevel.HIGH,
46
+ }
47
+
48
+ # ── Patrones absolutamente prohibidos (bloqueo siempre, incluso en BYPASS) ───
49
+ ALWAYS_BLOCK = [
50
+ "rm -rf /", "rm -rf ~", "dd if=/dev/zero", "mkfs",
51
+ "format c:", ":(){:|:&};:", "shutdown -h now", "reboot",
52
+ "chmod 777 /", "chown -R root /",
53
+ ]
54
+
55
+ # ── Patrones siempre seguros (auto-aprobados) ─────────────────────────────────
56
+ ALWAYS_ALLOW_CMDS = [
57
+ "echo", "cat ", "ls", "pwd", "whoami", "date", "uname",
58
+ "python --version", "python3 --version", "pip list",
59
+ "git status", "git log", "git diff", "git branch",
60
+ "pytest", "python -m pytest",
61
+ ]
62
+
63
+
64
+ @dataclass
65
+ class PermissionDecision:
66
+ approved: bool
67
+ reason: str = ""
68
+ remember: bool = False # "Aprobar siempre" en esta sesión
69
+
70
+
71
+ @dataclass
72
+ class DenialTracker:
73
+ counts: Dict[str, List[float]] = field(default_factory=dict)
74
+ WINDOW = 600 # 10 minutos
75
+ LIMIT = 3 # máximo rechazos antes de poner en reposo
76
+
77
+ def record(self, tool: str):
78
+ now = time.time()
79
+ self.counts.setdefault(tool, []).append(now)
80
+ self.counts[tool] = [t for t in self.counts[tool] if now - t < self.WINDOW]
81
+
82
+ def in_reposo(self, tool: str) -> bool:
83
+ return len(self.counts.get(tool, [])) >= self.LIMIT
84
+
85
+
86
+ class PermissionManager:
87
+ """Evalúa permisos para cada acción y gestiona auditoría."""
88
+
89
+ def __init__(self, mode: PermissionMode = PermissionMode.DEFAULT):
90
+ self.mode = mode
91
+ self._session_allows: Dict[str, bool] = {}
92
+ self._denial_tracker = DenialTracker()
93
+ self._audit: List[Dict] = []
94
+ self._ask_fn: Optional[Callable] = None # callback de UI
95
+
96
+ def set_ask_callback(self, fn: Callable):
97
+ self._ask_fn = fn
98
+
99
+ def set_mode(self, mode: PermissionMode):
100
+ self.mode = mode
101
+
102
+ def check(self, tool: str, description: str, args: Optional[Dict] = None) -> PermissionDecision:
103
+ args = args or {}
104
+
105
+ # ── Bloqueo absoluto siempre ──────────────────────────────────────────
106
+ if self._is_blocked(tool, args):
107
+ d = PermissionDecision(False, "⛔ Patrón peligroso bloqueado siempre")
108
+ self._log(tool, args, False, d.reason)
109
+ return d
110
+
111
+ # ── BYPASS: todo aprobado ─────────────────────────────────────────────
112
+ if self.mode == PermissionMode.BYPASS:
113
+ self._log(tool, args, True, "Modo BYPASS")
114
+ return PermissionDecision(True, "Modo BYPASS")
115
+
116
+ # ── PLAN: aprobado automáticamente ───────────────────────────────────
117
+ if self.mode == PermissionMode.PLAN:
118
+ self._log(tool, args, True, "Modo PLAN")
119
+ return PermissionDecision(True, "Modo PLAN")
120
+
121
+ # ── DEFAULT ───────────────────────────────────────────────────────────
122
+ risk = TOOL_RISK.get(tool, RiskLevel.MEDIUM)
123
+
124
+ # Bajo riesgo → siempre aprobado
125
+ if risk == RiskLevel.LOW:
126
+ self._log(tool, args, True, "Bajo riesgo")
127
+ return PermissionDecision(True, "Bajo riesgo")
128
+
129
+ # Ya aprobado en sesión
130
+ if tool in self._session_allows:
131
+ return PermissionDecision(True, "Aprobado en sesión")
132
+
133
+ # Loop de rechazos
134
+ if self._denial_tracker.in_reposo(tool):
135
+ d = PermissionDecision(
136
+ False,
137
+ f"'{tool}' pausada ({DenialTracker.LIMIT} rechazos recientes — usa /mode bypass si confías)"
138
+ )
139
+ self._log(tool, args, False, d.reason)
140
+ return d
141
+
142
+ # Comando conocido como seguro
143
+ cmd = str(args.get("cmd", "") or args.get("command", ""))
144
+ for safe in ALWAYS_ALLOW_CMDS:
145
+ if cmd.startswith(safe):
146
+ self._log(tool, args, True, "Patrón seguro")
147
+ return PermissionDecision(True, "Patrón seguro conocido")
148
+
149
+ # Pedir al usuario
150
+ if self._ask_fn:
151
+ decision = self._ask_fn(tool, description, risk, args)
152
+ if not decision.approved:
153
+ self._denial_tracker.record(tool)
154
+ if decision.approved and decision.remember:
155
+ self._session_allows[tool] = True
156
+ self._log(tool, args, decision.approved, decision.reason)
157
+ return decision
158
+
159
+ # Sin callback → aprobado por defecto
160
+ self._log(tool, args, True, "Sin callback de UI")
161
+ return PermissionDecision(True, "Auto-aprobado")
162
+
163
+ # ── Helpers ───────────────────────────────────────────────────────────────
164
+
165
+ def _is_blocked(self, tool: str, args: Dict) -> bool:
166
+ cmd = str(args.get("cmd", "") or args.get("command", "")).lower()
167
+ return any(p in cmd for p in ALWAYS_BLOCK)
168
+
169
+ def _log(self, tool: str, args: Dict, approved: bool, reason: str):
170
+ self._audit.append({
171
+ "ts": time.time(),
172
+ "tool": tool,
173
+ "args_preview": str(args)[:120],
174
+ "approved": approved,
175
+ "reason": reason,
176
+ })
177
+
178
+ def get_audit(self) -> List[Dict]:
179
+ return list(self._audit)
180
+
181
+ def export_audit(self, path: Path):
182
+ path.write_text(json.dumps(self._audit, indent=2, ensure_ascii=False), encoding="utf-8")
hanus/plan/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # hanus/plan/__init__.py
2
+ """
3
+ Sistema de planificación pre-ejecución.
4
+ """
5
+ from hanus.plan.models import Plan, PlanStep, PlanStatus, StepStatus
6
+ from hanus.plan.mode import PlanMode
7
+
8
+ __all__ = ["Plan", "PlanStep", "PlanStatus", "StepStatus", "PlanMode"]
hanus/plan/mode.py ADDED
@@ -0,0 +1,267 @@
1
+ # hanus/plan/mode.py
2
+ """
3
+ Sistema de modo planificación.
4
+ """
5
+ from __future__ import annotations
6
+ import json
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Callable, Any
10
+
11
+ from hanus.plan.models import Plan, PlanStep, PlanStatus, StepStatus
12
+
13
+
14
+ class PlanMode:
15
+ """
16
+ Gestiona el modo de planificación.
17
+
18
+ En este modo, el agente solo puede usar herramientas de lectura
19
+ y debe crear un plan antes de ejecutar cualquier modificación.
20
+
21
+ Uso:
22
+ plan_mode = PlanMode(root_dir)
23
+
24
+ # Entrar en modo plan
25
+ plan_mode.enter("Implementar sistema de autenticación")
26
+ # ... el agente trabaja en el plan ...
27
+
28
+ # Aprobar y ejecutar
29
+ if user_approves:
30
+ plan_mode.approve(plan_id)
31
+ for step in plan.get_steps():
32
+ # ejecutar cada paso
33
+ """
34
+
35
+ # Herramientas permitidas en modo plan
36
+ PLAN_TOOLS = {
37
+ "read_file", "list_files", "glob_search", "grep_search",
38
+ "git_status", "git_diff", "git_log", "web_fetch", "web_search",
39
+ "plan_add_step", "plan_update_step", "plan_list", "plan_get",
40
+ }
41
+
42
+ def __init__(self, root_dir: Path, ask_callback: Optional[Callable] = None):
43
+ self.root_dir = root_dir
44
+ self.ask = ask_callback
45
+ self._plans: Dict[str, Plan] = {}
46
+ self._active_plan: Optional[str] = None
47
+ self._in_plan_mode = False
48
+ self._plan_file = root_dir / ".hanus" / "plans.json"
49
+ self._load_plans()
50
+
51
+ def _load_plans(self):
52
+ """Carga planes desde disco."""
53
+ if not self._plan_file.exists():
54
+ return
55
+ try:
56
+ data = json.loads(self._plan_file.read_text(encoding="utf-8"))
57
+ for pid, pdata in data.get("plans", {}).items():
58
+ self._plans[pid] = Plan.from_dict(pdata)
59
+ except Exception:
60
+ pass
61
+
62
+ def _save_plans(self):
63
+ """Guarda planes a disco."""
64
+ self._plan_file.parent.mkdir(parents=True, exist_ok=True)
65
+ data = {
66
+ "plans": {pid: p.to_dict() for pid, p in self._plans.items()},
67
+ "active": self._active_plan,
68
+ }
69
+ self._plan_file.write_text(
70
+ json.dumps(data, indent=2, ensure_ascii=False),
71
+ encoding="utf-8"
72
+ )
73
+
74
+ # ─── Estado ────────────────────────────────────────────────────────────────
75
+
76
+ def is_active(self) -> bool:
77
+ """Retorna True si estamos en modo planificación."""
78
+ return self._in_plan_mode
79
+
80
+ def get_active_plan(self) -> Optional[Plan]:
81
+ """Obtiene el plan activo."""
82
+ if self._active_plan:
83
+ return self._plans.get(self._active_plan)
84
+ return None
85
+
86
+ def is_tool_allowed(self, tool_name: str) -> bool:
87
+ """Verifica si una herramienta está permitida en modo plan."""
88
+ if not self._in_plan_mode:
89
+ return True
90
+ return tool_name in self.PLAN_TOOLS
91
+
92
+ # ─── Operaciones ───────────────────────────────────────────────────────────
93
+
94
+ def enter(self, description: str = "") -> str:
95
+ """
96
+ Entra en modo planificación.
97
+
98
+ Args:
99
+ description: Descripción de lo que se va a planificar
100
+
101
+ Returns:
102
+ ID del plan creado
103
+ """
104
+ plan_id = str(uuid.uuid4())[:8]
105
+ plan = Plan(
106
+ id=plan_id,
107
+ title=f"Plan #{plan_id}",
108
+ description=description,
109
+ )
110
+ self._plans[plan_id] = plan
111
+ self._active_plan = plan_id
112
+ self._in_plan_mode = True
113
+ self._save_plans()
114
+ return plan_id
115
+
116
+ def exit(self) -> Optional[Plan]:
117
+ """
118
+ Sale del modo planificación sin aprobar.
119
+
120
+ Returns:
121
+ El plan activo o None si no había
122
+ """
123
+ plan = self.get_active_plan()
124
+ self._in_plan_mode = False
125
+ self._active_plan = None
126
+ self._save_plans()
127
+ return plan
128
+
129
+ def approve(self, plan_id: Optional[str] = None) -> bool:
130
+ """
131
+ Aprueba un plan para ejecución.
132
+
133
+ Args:
134
+ plan_id: ID del plan, o None para el activo
135
+
136
+ Returns:
137
+ True si se aprobó exitosamente
138
+ """
139
+ pid = plan_id or self._active_plan
140
+ if not pid or pid not in self._plans:
141
+ return False
142
+
143
+ plan = self._plans[pid]
144
+ plan.status = PlanStatus.APPROVED
145
+ self._in_plan_mode = False
146
+ self._save_plans()
147
+ return True
148
+
149
+ def reject(self, plan_id: Optional[str] = None) -> bool:
150
+ """
151
+ Rechaza un plan.
152
+
153
+ Args:
154
+ plan_id: ID del plan, o None para el activo
155
+ """
156
+ pid = plan_id or self._active_plan
157
+ if not pid or pid not in self._plans:
158
+ return False
159
+
160
+ del self._plans[pid]
161
+ if self._active_plan == pid:
162
+ self._active_plan = None
163
+ self._in_plan_mode = False
164
+ self._save_plans()
165
+ return True
166
+
167
+ # ─── Manipulación de planes ────────────────────────────────────────────────
168
+
169
+ def create_plan(self, title: str, description: str = "") -> Plan:
170
+ """Crea un nuevo plan y lo establece como activo."""
171
+ plan_id = str(uuid.uuid4())[:8]
172
+ plan = Plan(
173
+ id=plan_id,
174
+ title=title if title else f"Plan #{plan_id}",
175
+ description=description,
176
+ )
177
+ self._plans[plan_id] = plan
178
+ self._active_plan = plan_id # Establecer como activo
179
+ self._save_plans()
180
+ return plan
181
+
182
+ def add_step(self, plan_id: Optional[str], description: str) -> Optional[PlanStep]:
183
+ """Añade un paso a un plan."""
184
+ pid = plan_id or self._active_plan
185
+ if not pid or pid not in self._plans:
186
+ return None
187
+ step = self._plans[pid].add_step(description)
188
+ self._save_plans()
189
+ return step
190
+
191
+ def update_step(
192
+ self,
193
+ plan_id: Optional[str],
194
+ step_id: int,
195
+ status: Optional[StepStatus] = None,
196
+ notes: Optional[str] = None,
197
+ ) -> bool:
198
+ """Actualiza un paso del plan."""
199
+ pid = plan_id or self._active_plan
200
+ if not pid or pid not in self._plans:
201
+ return False
202
+ plan = self._plans[pid]
203
+ if status:
204
+ plan.mark_step(step_id, status, notes or "")
205
+ self._save_plans()
206
+ return True
207
+
208
+ def get_plan(self, plan_id: str) -> Optional[Plan]:
209
+ """Obtiene un plan por ID."""
210
+ return self._plans.get(plan_id)
211
+
212
+ def list_plans(self, status: Optional[PlanStatus] = None) -> List[Plan]:
213
+ """Lista planes, opcionalmente filtrados por estado."""
214
+ plans = list(self._plans.values())
215
+ if status:
216
+ plans = [p for p in plans if p.status == status]
217
+ return sorted(plans, key=lambda p: p.created_at, reverse=True)
218
+
219
+ def delete_plan(self, plan_id: str) -> bool:
220
+ """Elimina un plan."""
221
+ if plan_id in self._plans:
222
+ del self._plans[plan_id]
223
+ if self._active_plan == plan_id:
224
+ self._active_plan = None
225
+ self._in_plan_mode = False
226
+ self._save_plans()
227
+ return True
228
+ return False
229
+
230
+ def clear_completed(self):
231
+ """Elimina planes completados."""
232
+ to_remove = [
233
+ pid for pid, p in self._plans.items()
234
+ if p.status in (PlanStatus.COMPLETED, PlanStatus.FAILED)
235
+ ]
236
+ for pid in to_remove:
237
+ del self._plans[pid]
238
+ self._save_plans()
239
+
240
+ # ─── Interacción con usuario ────────────────────────────────────────────────
241
+
242
+ def request_approval(self, plan: Plan) -> bool:
243
+ """
244
+ Solicita aprobación del usuario para un plan.
245
+
246
+ Returns:
247
+ True si el usuario aprobó
248
+ """
249
+ if not self.ask:
250
+ return False
251
+
252
+ print()
253
+ print(plan.to_markdown())
254
+ print()
255
+
256
+ response = self.ask("¿Aprobar este plan? [S/n/e(ditar)] ")
257
+ if not response:
258
+ return True # Default: aprobar
259
+ r = response.lower().strip()
260
+ if r in ("s", "si", "sí", "y", "yes", ""):
261
+ return True
262
+ if r in ("n", "no"):
263
+ return False
264
+ if r in ("e", "editar", "edit"):
265
+ # TODO: permitir edición interactiva
266
+ return False
267
+ return True
hanus/plan/models.py ADDED
@@ -0,0 +1,152 @@
1
+ # hanus/plan/models.py
2
+ """
3
+ Modelos de datos para el sistema de planificación.
4
+ """
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from typing import List, Dict, Any, Optional
8
+ from enum import Enum
9
+
10
+
11
+ class PlanStatus(Enum):
12
+ """Estados de un plan."""
13
+ DRAFT = "draft" # En desarrollo
14
+ APPROVED = "approved" # Aprobado por usuario
15
+ EXECUTING = "executing" # En ejecución
16
+ COMPLETED = "completed" # Completado
17
+ FAILED = "failed" # Falló
18
+
19
+
20
+ class StepStatus(Enum):
21
+ """Estados de un paso del plan."""
22
+ PENDING = "pending"
23
+ IN_PROGRESS = "in_progress"
24
+ COMPLETED = "completed"
25
+ SKIPPED = "skipped"
26
+ FAILED = "failed"
27
+
28
+
29
+ @dataclass
30
+ class PlanStep:
31
+ """Un paso individual en un plan."""
32
+ id: int
33
+ description: str
34
+ status: StepStatus = StepStatus.PENDING
35
+ notes: str = ""
36
+ files_modified: List[str] = field(default_factory=list)
37
+
38
+ def to_dict(self) -> Dict[str, Any]:
39
+ return {
40
+ "id": self.id,
41
+ "description": self.description,
42
+ "status": self.status.value,
43
+ "notes": self.notes,
44
+ "files_modified": self.files_modified,
45
+ }
46
+
47
+ @classmethod
48
+ def from_dict(cls, d: Dict) -> PlanStep:
49
+ return cls(
50
+ id=d["id"],
51
+ description=d["description"],
52
+ status=StepStatus(d.get("status", "pending")),
53
+ notes=d.get("notes", ""),
54
+ files_modified=d.get("files_modified", []),
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class Plan:
60
+ """Un plan de implementación completo."""
61
+ id: str
62
+ title: str
63
+ description: str
64
+ steps: List[PlanStep] = field(default_factory=list)
65
+ status: PlanStatus = PlanStatus.DRAFT
66
+ created_at: float = field(default_factory=lambda: __import__("time").time())
67
+ metadata: Dict[str, Any] = field(default_factory=dict)
68
+
69
+ def add_step(self, description: str) -> PlanStep:
70
+ """Añade un nuevo paso al plan."""
71
+ step_id = len(self.steps) + 1
72
+ step = PlanStep(id=step_id, description=description)
73
+ self.steps.append(step)
74
+ return step
75
+
76
+ def get_step(self, step_id: int) -> Optional[PlanStep]:
77
+ """Obtiene un paso por su ID."""
78
+ for step in self.steps:
79
+ if step.id == step_id:
80
+ return step
81
+ return None
82
+
83
+ def mark_step(self, step_id: int, status: StepStatus, notes: str = "") -> bool:
84
+ """Actualiza el estado de un paso."""
85
+ step = self.get_step(step_id)
86
+ if step:
87
+ step.status = status
88
+ if notes:
89
+ step.notes = notes
90
+ return True
91
+ return False
92
+
93
+ def current_step(self) -> Optional[PlanStep]:
94
+ """Obtiene el paso actual (primer pending o in_progress)."""
95
+ for step in self.steps:
96
+ if step.status in (StepStatus.PENDING, StepStatus.IN_PROGRESS):
97
+ return step
98
+ return None
99
+
100
+ def progress(self) -> tuple:
101
+ """Retorna (completados, total)."""
102
+ completed = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
103
+ return completed, len(self.steps)
104
+
105
+ def is_complete(self) -> bool:
106
+ """Verifica si todos los pasos están completados o saltados."""
107
+ return all(
108
+ s.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
109
+ for s in self.steps
110
+ )
111
+
112
+ def to_markdown(self) -> str:
113
+ """Convierte el plan a formato markdown."""
114
+ lines = [f"# Plan: {self.title}", "", f"{self.description}", ""]
115
+ for step in self.steps:
116
+ icon = {
117
+ StepStatus.PENDING: "⏳",
118
+ StepStatus.IN_PROGRESS: "🔄",
119
+ StepStatus.COMPLETED: "✅",
120
+ StepStatus.SKIPPED: "⏭️",
121
+ StepStatus.FAILED: "❌",
122
+ }.get(step.status, "⏳")
123
+ lines.append(f"{icon} **{step.id}.** {step.description}")
124
+ if step.notes:
125
+ lines.append(f" _{step.notes}_")
126
+ completed, total = self.progress()
127
+ lines.append("")
128
+ lines.append(f"_Progreso: {completed}/{total} pasos_")
129
+ return "\n".join(lines)
130
+
131
+ def to_dict(self) -> Dict[str, Any]:
132
+ return {
133
+ "id": self.id,
134
+ "title": self.title,
135
+ "description": self.description,
136
+ "steps": [s.to_dict() for s in self.steps],
137
+ "status": self.status.value,
138
+ "created_at": self.created_at,
139
+ "metadata": self.metadata,
140
+ }
141
+
142
+ @classmethod
143
+ def from_dict(cls, d: Dict) -> Plan:
144
+ return cls(
145
+ id=d["id"],
146
+ title=d["title"],
147
+ description=d["description"],
148
+ steps=[PlanStep.from_dict(s) for s in d.get("steps", [])],
149
+ status=PlanStatus(d.get("status", "draft")),
150
+ created_at=d.get("created_at", 0),
151
+ metadata=d.get("metadata", {}),
152
+ )