diff --git a/tracker/__init__.py b/tracker/__init__.py new file mode 100644 index 0000000..826d82d --- /dev/null +++ b/tracker/__init__.py @@ -0,0 +1 @@ +"""Personal Tracker CLI - A Markdown-based project tracking system.""" diff --git a/tracker/models/__init__.py b/tracker/models/__init__.py new file mode 100644 index 0000000..6e04711 --- /dev/null +++ b/tracker/models/__init__.py @@ -0,0 +1,7 @@ +"""Data models for the tracker.""" +from .project import Project +from .session import Session +from .note import Note, NoteType +from .change import Change + +__all__ = ["Project", "Session", "Note", "NoteType", "Change"] diff --git a/tracker/models/change.py b/tracker/models/change.py new file mode 100644 index 0000000..a94bb65 --- /dev/null +++ b/tracker/models/change.py @@ -0,0 +1,13 @@ +"""Change model definition.""" +from pydantic import BaseModel, Field +from datetime import date + + +class Change(BaseModel): + """Represents a notable change in a project.""" + + date: date + type: str # code, infra, config, docs, automation, decision + title: str + impact: str = "" + references: list[str] = Field(default_factory=list) diff --git a/tracker/models/note.py b/tracker/models/note.py new file mode 100644 index 0000000..5ebbfb8 --- /dev/null +++ b/tracker/models/note.py @@ -0,0 +1,22 @@ +"""Note model definition.""" +from enum import Enum +from pydantic import BaseModel, Field +from datetime import datetime + + +class NoteType(Enum): + """Types of notes that can be recorded during a session.""" + WORK = "work" + CHANGE = "change" + BLOCKER = "blocker" + DECISION = "decision" + IDEA = "idea" + REFERENCE = "reference" + + +class Note(BaseModel): + """Represents a note recorded during a session.""" + + type: NoteType + text: str + created_at: datetime = Field(default_factory=datetime.now) diff --git a/tracker/models/project.py b/tracker/models/project.py new file mode 100644 index 0000000..d2d9a95 --- /dev/null +++ b/tracker/models/project.py @@ -0,0 +1,21 @@ +"""Project model definition.""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + + +class Project(BaseModel): + """Represents a tracked project.""" + + id: str + name: str + slug: str + description: str = "" + type: str = "misc" # code, homelab, automation, agent, research, misc + status: str = "inbox" # inbox, next, active, blocked, waiting, done, archived + tags: list[str] = Field(default_factory=list) + root_path: str = "" + repo_path: Optional[str] = None + created_at: datetime + updated_at: datetime + last_session_at: Optional[datetime] = None diff --git a/tracker/models/session.py b/tracker/models/session.py new file mode 100644 index 0000000..1de3647 --- /dev/null +++ b/tracker/models/session.py @@ -0,0 +1,23 @@ +"""Session model definition.""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + + +class Session(BaseModel): + """Represents a work session on a project.""" + + id: str + project_slug: str + started_at: datetime + ended_at: Optional[datetime] = None + duration_minutes: Optional[int] = None + objective: str = "" + summary: str = "" + work_done: list[str] = Field(default_factory=list) + changes: list[str] = Field(default_factory=list) + decisions: list[str] = Field(default_factory=list) + blockers: list[str] = Field(default_factory=list) + next_steps: list[str] = Field(default_factory=list) + references: list[str] = Field(default_factory=list) + raw_notes: list[dict] = Field(default_factory=list) # [{"type": "work", "text": "...", "timestamp": "..."}] diff --git a/tracker/services/__init__.py b/tracker/services/__init__.py new file mode 100644 index 0000000..5178642 --- /dev/null +++ b/tracker/services/__init__.py @@ -0,0 +1,45 @@ +"""Services layer for business logic.""" + +from .session_service import ( + get_active_session, + set_active_session, + clear_active_session, + get_active_session_path, + validate_no_other_active_session, +) +from .project_service import ( + create_project, + get_project, + update_project, + list_projects, + get_projects_root, + ensure_project_structure, +) +from .note_service import ( + add_note, + consolidate_notes, +) +from .heuristics_service import ( + suggest_next_steps, +) +from .summary_service import ( + generate_summary, +) + +__all__ = [ + "get_active_session", + "set_active_session", + "clear_active_session", + "get_active_session_path", + "validate_no_other_active_session", + "create_project", + "get_project", + "update_project", + "list_projects", + "get_projects_root", + "ensure_project_structure", + "add_note", + "consolidate_notes", + "suggest_next_steps", + "generate_summary", +] diff --git a/tracker/services/heuristics_service.py b/tracker/services/heuristics_service.py new file mode 100644 index 0000000..b4f3f16 --- /dev/null +++ b/tracker/services/heuristics_service.py @@ -0,0 +1,47 @@ +"""Heuristics service for suggestions based on rules.""" + +from datetime import datetime + +from ..models import Session, Project + + +def suggest_next_steps(session: Session, project: Project) -> list[str]: + """ + Generate suggestions based on session state and project context. + Rules: + - si hay blockers abiertos, sugerir "Destrabar: [bloqueos]" + - si hay changes sin references, sugerir "Validar cambios recientes" + - si work_done está vacío y session > 30 min, sugerir "Revisar progreso del objetivo" + - si no hay next_steps definidos, sugerir "Definir próximos pasos" + """ + suggestions = [] + + # Rule: blockers open + if session.blockers: + for blocker in session.blockers: + suggestions.append(f"Destrabar: {blocker}") + + # Rule: changes without references + changes_without_refs = [] + for change in session.changes: + # Simple heuristic: if change doesn't reference anything specific + if change and not any(ref in change.lower() for ref in ["#", "commit", "pr", "issue"]): + changes_without_refs.append(change) + + if changes_without_refs: + suggestions.append("Validar cambios recientes") + + # Rule: work_done empty and session > 30 minutes + if not session.work_done: + duration = session.duration_minutes + if duration == 0 and session.ended_at and session.started_at: + duration = int((session.ended_at - session.started_at).total_seconds() / 60) + + if duration > 30: + suggestions.append("Revisar progreso del objetivo") + + # Rule: no next_steps defined + if not session.next_steps: + suggestions.append("Definir próximos pasos") + + return suggestions diff --git a/tracker/services/note_service.py b/tracker/services/note_service.py new file mode 100644 index 0000000..6c911b3 --- /dev/null +++ b/tracker/services/note_service.py @@ -0,0 +1,65 @@ +"""Note service for note management.""" + +from datetime import datetime + +from ..models import Session, NoteType, Note + + +def add_note(session: Session, note_type: str, text: str) -> dict: + """ + Add a note to the session and return the note dict. + Valid note types: work, change, blocker, decision, idea, reference + """ + try: + note_type_enum = NoteType(note_type) + except ValueError: + raise ValueError(f"Invalid note type: {note_type}. Valid types are: {[t.value for t in NoteType]}") + + note = Note(type=note_type_enum, text=text) + session.raw_notes.append(note.model_dump(mode="json")) + + return { + "type": note.type.value, + "text": note.text, + "created_at": note.created_at.isoformat(), + } + + +def consolidate_notes(raw_notes: list[dict]) -> dict: + """ + Consolidate raw notes into categorized sections. + Returns dict with keys: work_done, changes, decisions, blockers, references + """ + result = { + "work_done": [], + "changes": [], + "decisions": [], + "blockers": [], + "references": [], + } + + for note in raw_notes: + if isinstance(note, dict): + note_type = note.get("type", "") + text = note.get("text", "") + else: + # Handle string format like "[type] text" + parts = note.split("]", 1) + if len(parts) == 2: + note_type = parts[0][1:] + text = parts[1].strip() + else: + continue + + if note_type == NoteType.WORK.value: + result["work_done"].append(text) + elif note_type == NoteType.CHANGE.value: + result["changes"].append(text) + elif note_type == NoteType.DECISION.value: + result["decisions"].append(text) + elif note_type == NoteType.BLOCKER.value: + result["blockers"].append(text) + elif note_type == NoteType.REFERENCE.value: + result["references"].append(text) + + return result diff --git a/tracker/services/project_service.py b/tracker/services/project_service.py new file mode 100644 index 0000000..3b17f8f --- /dev/null +++ b/tracker/services/project_service.py @@ -0,0 +1,118 @@ +"""Project service for project management.""" + +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from ..models import Project + + +_PROJECTS_ROOT = Path("projects") + + +def get_projects_root() -> Path: + """Return the root directory for all projects.""" + return _PROJECTS_ROOT + + +def _get_project_meta_path(slug: str) -> Path: + """Return the path to the project's meta/project.yaml file.""" + return _PROJECTS_ROOT / slug / "meta" / "project.yaml" + + +def _get_project_readme_path(slug: str) -> Path: + """Return the path to the project's README.md file.""" + return _PROJECTS_ROOT / slug / "README.md" + + +def create_project( + name: str, + slug: str, + description: str = "", + type: str = "misc", + tags: Optional[list[str]] = None, + repo_path: Optional[Path] = None, +) -> Project: + """ + Create a new project and return the Project instance. + Note: This does not write any files - that is handled by storage. + """ + if tags is None: + tags = [] + + project = Project( + id=str(uuid.uuid4()), + name=name, + slug=slug, + description=description, + type=type, + status="inbox", + tags=tags, + root_path=_PROJECTS_ROOT / slug, + repo_path=repo_path, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return project + + +def get_project(slug: str) -> Optional[Project]: + """ + Get a project by slug. + Note: This reads from file system - placeholder for storage integration. + """ + meta_path = _get_project_meta_path(slug) + if not meta_path.exists(): + return None + # TODO: Load from storage (YAML) + return None + + +def update_project(slug: str, **kwargs) -> Optional[Project]: + """ + Update a project's attributes. + Note: This does not persist - that is handled by storage. + """ + project = get_project(slug) + if project is None: + return None + + for key, value in kwargs.items(): + if hasattr(project, key): + setattr(project, key, value) + + project.updated_at = datetime.now() + return project + + +def list_projects() -> list[Project]: + """ + List all projects. + Note: This reads from file system - placeholder for storage integration. + """ + projects_root = get_projects_root() + if not projects_root.exists(): + return [] + + projects = [] + for item in projects_root.iterdir(): + if item.is_dir() and not item.name.startswith("."): + project = get_project(item.name) + if project is not None: + projects.append(project) + + return projects + + +def ensure_project_structure(slug: str) -> None: + """ + Ensure the project directory structure exists. + Creates: sessions/, docs/, assets/, meta/ + Note: This creates directories only - actual file writing is storage's job. + """ + project_root = _PROJECTS_ROOT / slug + directories = ["sessions", "docs", "assets", "meta"] + + for directory in directories: + (project_root / directory).mkdir(parents=True, exist_ok=True) diff --git a/tracker/services/session_service.py b/tracker/services/session_service.py new file mode 100644 index 0000000..21e7a43 --- /dev/null +++ b/tracker/services/session_service.py @@ -0,0 +1,67 @@ +"""Session service for active session management.""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Optional + +from ..models import Session + + +_ACTIVE_SESSION_FILE = ".active_session.json" + + +def get_active_session_path() -> Path: + """Return the path to the active session file in projects/ directory.""" + return Path("projects") / _ACTIVE_SESSION_FILE + + +def get_active_session() -> Optional[Session]: + """Load and return the currently active session, or None if none exists.""" + path = get_active_session_path() + if not path.exists(): + return None + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + # Convert started_at string back to datetime + data["started_at"] = datetime.fromisoformat(data["started_at"]) + if data.get("ended_at"): + data["ended_at"] = datetime.fromisoformat(data["ended_at"]) + + return Session(**data) + + +def set_active_session(session: Session) -> None: + """Save the given session as the active session.""" + path = get_active_session_path() + path.parent.mkdir(parents=True, exist_ok=True) + + data = session.model_dump(mode="json") + # Serialize datetime objects to ISO format + data["started_at"] = session.started_at.isoformat() + if session.ended_at: + data["ended_at"] = session.ended_at.isoformat() + + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def clear_active_session() -> None: + """Remove the active session file.""" + path = get_active_session_path() + if path.exists(): + path.unlink() + + +def validate_no_other_active_session(project_slug: str) -> bool: + """ + Check if there is an active session for a different project. + Returns True if no conflict exists (i.e., either no active session + or the active session belongs to the same project). + """ + active = get_active_session() + if active is None: + return True + return active.project_slug == project_slug diff --git a/tracker/services/summary_service.py b/tracker/services/summary_service.py new file mode 100644 index 0000000..8a9d763 --- /dev/null +++ b/tracker/services/summary_service.py @@ -0,0 +1,42 @@ +"""Summary service for heuristic summary generation.""" + +from ..models import Session +from .note_service import consolidate_notes + + +def generate_summary(session: Session) -> str: + """ + Generate a heuristic summary from the session. + Uses consolidate_notes to extract work_done, decisions, blockers. + """ + # Consolidate raw notes into categorized sections + consolidated = consolidate_notes(session.raw_notes) + + lines = [] + + # Work done section + if consolidated["work_done"]: + lines.append("Trabajo realizado:") + for item in consolidated["work_done"]: + lines.append(f" - {item}") + lines.append("") + + # Decisions section + if consolidated["decisions"]: + lines.append("Decisiones:") + for item in consolidated["decisions"]: + lines.append(f" - {item}") + lines.append("") + + # Blockers section + if consolidated["blockers"]: + lines.append("Bloqueos:") + for item in consolidated["blockers"]: + lines.append(f" - {item}") + lines.append("") + + # If no content, provide a minimal summary + if not lines: + return f"Session de {session.duration_minutes} minutos sin progreso registrado." + + return "\n".join(lines) diff --git a/tracker/storage/__init__.py b/tracker/storage/__init__.py new file mode 100644 index 0000000..661ab23 --- /dev/null +++ b/tracker/storage/__init__.py @@ -0,0 +1,5 @@ +from tracker.storage.file_storage import FileStorage +from tracker.storage.markdown_reader import MarkdownReader +from tracker.storage.markdown_writer import MarkdownWriter + +__all__ = ["FileStorage", "MarkdownReader", "MarkdownWriter"] diff --git a/tracker/storage/file_storage.py b/tracker/storage/file_storage.py new file mode 100644 index 0000000..ffdce31 --- /dev/null +++ b/tracker/storage/file_storage.py @@ -0,0 +1,158 @@ +"""Storage layer for file-based persistence.""" + +import json +from pathlib import Path +from typing import Optional + +import yaml + +from tracker.models.session import Session + + +class FileStorage: + """Maneja lectura/escritura de archivos del proyecto.""" + + def __init__(self, projects_root: Path): + self.projects_root = projects_root + + def _project_path(self, slug: str) -> Path: + return self.projects_root / slug + + def _meta_path(self, slug: str) -> Path: + return self._project_path(slug) / "meta" / "project.yaml" + + def _log_path(self, slug: str) -> Path: + return self._project_path(slug) / "LOG.md" + + def _changelog_path(self, slug: str) -> Path: + return self._project_path(slug) / "CHANGELOG.md" + + def _tasks_path(self, slug: str) -> Path: + return self._project_path(slug) / "TASKS.md" + + def _readme_path(self, slug: str) -> Path: + return self._project_path(slug) / "README.md" + + def _sessions_path(self, slug: str) -> Path: + return self._project_path(slug) / "sessions" + + def read_project_meta(self, slug: str) -> dict: + """Lee projects//meta/project.yaml""" + meta_path = self._meta_path(slug) + with open(meta_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + def write_project_meta(self, slug: str, data: dict) -> None: + """Escribe projects//meta/project.yaml""" + meta_path = self._meta_path(slug) + meta_path.parent.mkdir(parents=True, exist_ok=True) + with open(meta_path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True) + + def read_log(self, slug: str) -> str: + """Lee projects//LOG.md""" + log_path = self._log_path(slug) + if not log_path.exists(): + return "" + with open(log_path, "r", encoding="utf-8") as f: + return f.read() + + def append_to_log(self, slug: str, entry: str) -> None: + """Append a LOG.md entry.""" + log_path = self._log_path(slug) + with open(log_path, "a", encoding="utf-8") as f: + f.write(entry) + + def read_changelog(self, slug: str) -> str: + """Lee projects//CHANGELOG.md""" + changelog_path = self._changelog_path(slug) + if not changelog_path.exists(): + return "" + with open(changelog_path, "r", encoding="utf-8") as f: + return f.read() + + def append_to_changelog(self, slug: str, change: str) -> None: + """Append a CHANGELOG.md entry.""" + changelog_path = self._changelog_path(slug) + with open(changelog_path, "a", encoding="utf-8") as f: + f.write(change) + + def read_tasks(self, slug: str) -> str: + """Lee projects//TASKS.md""" + tasks_path = self._tasks_path(slug) + if not tasks_path.exists(): + return "" + with open(tasks_path, "r", encoding="utf-8") as f: + return f.read() + + def write_tasks(self, slug: str, tasks_content: str) -> None: + """Escribe projects//TASKS.md""" + tasks_path = self._tasks_path(slug) + with open(tasks_path, "w", encoding="utf-8") as f: + f.write(tasks_content) + + def read_readme(self, slug: str) -> str: + """Lee projects//README.md""" + readme_path = self._readme_path(slug) + if not readme_path.exists(): + return "" + with open(readme_path, "r", encoding="utf-8") as f: + return f.read() + + def update_readme_autogen(self, slug: str, section: str, content: str) -> None: + """Actualiza una seccion autogenerada en README.md. + + Busca ... + y reemplaza el contenido entre esos marcadores. + """ + from tracker.storage.markdown_writer import MarkdownWriter + + readme_path = self._readme_path(slug) + current_content = self.read_readme(slug) + + writer = MarkdownWriter() + new_content = writer.format_autogen_section(current_content, section, content) + + with open(readme_path, "w", encoding="utf-8") as f: + f.write(new_content) + + def write_session_file(self, session: Session) -> None: + """Crea projects//sessions/YYYY-MM-DD_HHMM.md""" + from tracker.storage.markdown_writer import MarkdownWriter + + sessions_path = self._sessions_path(session.project_slug) + sessions_path.mkdir(parents=True, exist_ok=True) + + started = session.started_at + filename = started.strftime("%Y-%m-%d_%H%M.md") + session_path = sessions_path / filename + + writer = MarkdownWriter() + content = writer.format_session_file(session) + + with open(session_path, "w", encoding="utf-8") as f: + f.write(content) + + def active_session_path(self) -> Path: + """Returns Path to projects/.active_session.json""" + return self.projects_root / ".active_session.json" + + def read_active_session(self) -> Optional[dict]: + """Lee la sesion activa desde .active_session.json""" + path = self.active_session_path() + if not path.exists(): + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + def write_active_session(self, session_data: dict) -> None: + """Escribe la sesion activa a .active_session.json""" + path = self.active_session_path() + with open(path, "w", encoding="utf-8") as f: + json.dump(session_data, f, indent=2, default=str) + + def delete_active_session(self) -> None: + """Elimina .active_session.json""" + path = self.active_session_path() + if path.exists(): + path.unlink() diff --git a/tracker/storage/markdown_reader.py b/tracker/storage/markdown_reader.py new file mode 100644 index 0000000..3b96620 --- /dev/null +++ b/tracker/storage/markdown_reader.py @@ -0,0 +1,138 @@ +"""Markdown reader utility.""" + +import re +from datetime import datetime +from typing import Optional + + +class MarkdownReader: + """Lectura de archivos Markdown del proyecto.""" + + def parse_log_entry(self, content: str) -> dict: + """Parse una entrada de LOG.md. + + Formato esperado: + ## 2026-03-23 10:00–11:20 + **Objetivo** + ... + **Trabajo realizado** + - ... + **Cambios relevantes** + - ... + **Bloqueos** + - ... + **Decisiones** + - ... + **Próximos pasos** + - ... + **Resumen** + ... + + Returns dict con: + - date_range: str + - objective: str + - work_done: list[str] + - changes: list[str] + - blockers: list[str] + - decisions: list[str] + - next_steps: list[str] + - summary: str + """ + result = { + "date_range": "", + "objective": "", + "work_done": [], + "changes": [], + "blockers": [], + "decisions": [], + "next_steps": [], + "summary": "", + } + + # Extraer fecha/rango + date_match = re.search(r"##\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}[–-]\d{2}:\d{2})", content) + if date_match: + result["date_range"] = date_match.group(1) + + # Extraer secciones + sections = { + "objective": r"\*\*Objetivo\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "work_done": r"\*\*Trabajo realizado\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "changes": r"\*\*Cambios relevantes\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "blockers": r"\*\*Bloqueos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "decisions": r"\*\*Decisiones\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "next_steps": r"\*\*Próximos pasos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)", + "summary": r"\*\*Resumen\*\*\s*\n(.*?)(?=\n##|\Z)", + } + + for key, pattern in sections.items(): + match = re.search(pattern, content, re.DOTALL) + if match: + text = match.group(1).strip() + if key in ("work_done", "changes", "blockers", "decisions", "next_steps"): + # Extraer listas con bullet points + items = re.findall(r"^\s*-\s+(.+)$", text, re.MULTILINE) + result[key] = items + else: + result[key] = text + + return result + + def extract_autogen_section(self, content: str, section: str) -> str: + """Extrae contenido de una seccion AUTOGEN. + + Busca ... + Returns el contenido entre esos marcadores, o string vacio si no existe. + """ + pattern = rf"(.*?)" + match = re.search(pattern, content, re.DOTALL) + if match: + return match.group(1).strip() + return "" + + def parse_tasks(self, content: str) -> dict: + """Parse TASKS.md por secciones. + + Secciones esperadas: + - Inbox + - Próximo + - En curso + - Bloqueado + - En espera + - Hecho + + Returns dict con nombre de seccion -> lista de tareas + """ + result = {} + current_section = None + current_tasks = [] + + lines = content.split("\n") + + for line in lines: + # Detectar headers de seccion (## ) + section_match = re.match(r"^##\s+(.+)$", line) + if section_match: + # Guardar seccion anterior + if current_section is not None: + result[current_section] = current_tasks + + current_section = section_match.group(1).strip() + current_tasks = [] + elif current_section is not None: + # Parsear bullet points + task_match = re.match(r"^\s*-\s+\[([ x])\]\s*(.+)$", line) + if task_match: + checked = task_match.group(1) == "x" + task_text = task_match.group(2).strip() + current_tasks.append({"text": task_text, "done": checked}) + elif line.strip(): + # Lineas que no son bullet ni header, agregar a la ultima tarea + if current_tasks: + current_tasks[-1]["text"] += " " + line.strip() + + # Guardar ultima seccion + if current_section is not None: + result[current_section] = current_tasks + + return result diff --git a/tracker/storage/markdown_writer.py b/tracker/storage/markdown_writer.py new file mode 100644 index 0000000..7d1358b --- /dev/null +++ b/tracker/storage/markdown_writer.py @@ -0,0 +1,241 @@ +"""Markdown writer utility.""" + +import re +from datetime import datetime +from typing import Optional + +from tracker.models.session import Session + + +class MarkdownWriter: + """Escritura de archivos Markdown del proyecto.""" + + def format_log_entry(self, session: Session, summary: str) -> str: + """Formatea una entrada para LOG.md. + + Formato: + ## 2026-03-23 10:00–11:20 + **Objetivo** + ... + + **Trabajo realizado** + - ... + + **Cambios relevantes** + - ... + + **Bloqueos** + - ... + + **Decisiones** + - ... + + **Próximos pasos** + - ... + + **Resumen** + ... + + Returns string formateado. + """ + started = session.started_at.strftime("%Y-%m-%d %H:%M") + ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso" + date_range = f"{started}–{ended}" + + lines = [ + f"## {date_range}", + "", + "**Objetivo**", + f"{session.objective or 'No especificado'}", + "", + "**Trabajo realizado**", + ] + + if session.work_done: + for item in session.work_done: + lines.append(f"- {item}") + else: + lines.append("- Sin trabajo registrado") + + lines.extend(["", "**Cambios relevantes**"]) + if session.changes: + for item in session.changes: + lines.append(f"- {item}") + else: + lines.append("- Sin cambios") + + lines.extend(["", "**Bloqueos**"]) + if session.blockers: + for item in session.blockers: + lines.append(f"- {item}") + else: + lines.append("- Sin bloqueos") + + lines.extend(["", "**Decisiones**"]) + if session.decisions: + for item in session.decisions: + lines.append(f"- {item}") + else: + lines.append("- Sin decisiones") + + lines.extend(["", "**Próximos pasos**"]) + if session.next_steps: + for item in session.next_steps: + lines.append(f"- {item}") + else: + lines.append("- Sin pasos definidos") + + lines.extend(["", "**Resumen**", summary]) + + return "\n".join(lines) + "\n\n" + + def format_session_file(self, session: Session) -> str: + """Formatea archivo de sesion detalle en sessions/YYYY-MM-DD_HHMM.md. + + Formato: + # Sesion: 2026-03-23 10:00–11:20 + + ## Objetivo + ... + + ## Notas + ... + + ## Trabajo realizado + ... + + ## Cambios + ... + + ## Decisiones + ... + + ## Bloqueos + ... + + ## Proximos pasos + ... + + ## Referencias + ... + + ## Duracion + X minutos + """ + started = session.started_at.strftime("%Y-%m-%d %H:%M") + ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso" + + lines = [ + f"# Sesion: {started}–{ended}", + "", + "## Objetivo", + f"{session.objective or 'No especificado'}", + "", + "## Notas", + ] + + if session.raw_notes: + for note in session.raw_notes: + note_type = note.get("type", "work") + note_text = note.get("text", "") + lines.append(f"- [{note_type}] {note_text}") + else: + lines.append("- Sin notas") + + lines.extend([ + "", + "## Trabajo realizado", + ]) + if session.work_done: + for item in session.work_done: + lines.append(f"- {item}") + else: + lines.append("- Sin trabajo realizado") + + lines.extend([ + "", + "## Cambios", + ]) + if session.changes: + for item in session.changes: + lines.append(f"- {item}") + else: + lines.append("- Sin cambios") + + lines.extend([ + "", + "## Decisiones", + ]) + if session.decisions: + for item in session.decisions: + lines.append(f"- {item}") + else: + lines.append("- Sin decisiones") + + lines.extend([ + "", + "## Bloqueos", + ]) + if session.blockers: + for item in session.blockers: + lines.append(f"- {item}") + else: + lines.append("- Sin bloqueos") + + lines.extend([ + "", + "## Proximos pasos", + ]) + if session.next_steps: + for item in session.next_steps: + lines.append(f"- {item}") + else: + lines.append("- Sin pasos definidos") + + lines.extend([ + "", + "## Referencias", + ]) + if session.references: + for item in session.references: + lines.append(f"- {item}") + else: + lines.append("- Sin referencias") + + lines.extend([ + "", + "## Duracion", + f"{session.duration_minutes} minutos", + ]) + + return "\n".join(lines) + "\n" + + def format_autogen_section(self, content: str, section: str, new_content: str) -> str: + """Reemplaza o inserta una seccion AUTOGEN en contenido Markdown. + + Busca ... + Si existe, reemplaza el contenido entre los marcadores. + Si no existe, inserta la seccion al final. + + Returns el contenido modificado. + """ + start_marker = f"" + end_marker = f"" + + full_marker = f"{start_marker}\n{new_content}\n{end_marker}" + + # Buscar si existe la seccion + pattern = rf"{re.escape(start_marker)}.*?{re.escape(end_marker)}" + if re.search(pattern, content, re.DOTALL): + # Reemplazar seccion existente + return re.sub(pattern, full_marker, content, flags=re.DOTALL) + else: + # Insertar al final + return content + "\n" + full_marker + "\n" + + def format_readme_section(self, section: str, content: str) -> str: + """Formatea una seccion de README.md. + + Para usar con format_autogen_section. + """ + return content