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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- 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
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
|
+
)
|