Compare commits

..

14 Commits

Author SHA1 Message Date
a3dcdb8577 prueba de proyecto 2026-03-25 00:29:33 -03:00
aedc647fff Document task commands in README 2026-03-25 00:27:25 -03:00
bd48122db2 Add task management commands to CLI
Implement task add and task move subcommands for managing tasks in TASKS.md.

task add <slug> <title> [--section <section>]:
- Adds a task to the specified section (default: Inbox)
- Valid sections: Inbox, Proximo, En curso, Bloqueado, En espera, Hecho
- Creates section if it doesn't exist

task move <slug> <task_title> <to_section>:
- Searches for task by title across all sections
- Moves task to destination section
- Marks task as completed ([x]) when moved to Hecho
- Updates checkbox state based on destination

Includes parsing and building functions for TASKS.md with:
- Section normalization (English to Spanish names)
- Merge support for duplicate normalized sections
- Standard section ordering

Uses app.add_typer to register task subcommand group.
2026-03-25 00:26:37 -03:00
4212a17e4b Add comprehensive .gitignore for Python project 2026-03-25 00:08:45 -03:00
cc523607d1 Fix pyproject.toml package discovery to avoid flat-layout conflict 2026-03-24 23:34:05 -03:00
b4593c69af Add detailed installation guide to README 2026-03-24 23:29:28 -03:00
4d99213d75 Update README.md with MVP-1 documentation 2026-03-23 17:09:44 -03:00
2735562b65 Add comprehensive test suite for MVP-1 Personal Tracker CLI
Implements 72 tests covering:
- Model tests (Project, Session, Note, Change)
- ProjectService tests (create, get, list, ensure structure)
- SessionService tests (active session management)
- FileStorage tests (read/write operations)
- Complete flow tests (init -> start -> note -> stop -> show)
- Note consolidation tests

Uses pytest with tmp_path fixtures for isolated testing.
2026-03-23 09:40:46 -03:00
4e67062c99 Add demo project for Personal Tracker MVP-1
Create example project structure in examples/demo-project/ with:
- Project metadata in meta/project.yaml
- README, LOG, CHANGELOG and TASKS documentation
- Two detailed session files showing real work examples
- Empty docs/ and assets/ directories

The demo project serves as a reference implementation showing
how to use the Personal Tracker CLI effectively.
2026-03-23 09:05:10 -03:00
b36b60353d Implement complete CLI commands for MVP-1 Personal Tracker
- Refactored CLI commands from nested Typer subapps to direct command functions
- Fixed main.py to use app.command() instead of app.add_typer_command()
- Fixed project_service.py to properly load projects from YAML
- Fixed file_storage.py to save session JSON files alongside markdown
- Added missing methods: write_file, read_file, extract_autogen_section, get_recent_sessions
- Fixed root_path and repo_path to use strings instead of Path objects
2026-03-23 09:02:21 -03:00
40a33d773b Implement project templates for file generation 2026-03-23 08:55:48 -03:00
88a474a78d Implement CLI commands and utility functions for MVP-1
- Add complete CLI command implementations with Typer subcommands
- Implement utils/time.py with duration formatting and datetime utilities
- Implement utils/path.py with project path management utilities
- Wire up all commands to main CLI entry point
2026-03-23 08:55:41 -03:00
b0c65a00a2 Implement core services for MVP-1 Personal Tracker CLI
Services implemented:
- session_service: Active session management (get/set/clear, validation)
- project_service: Project CRUD and directory structure
- note_service: Note handling and consolidation
- heuristics_service: Rule-based suggestions
- summary_service: Heuristic summary generation
2026-03-23 08:54:09 -03:00
4547c492da Implement storage layer for MVP-1 Personal Tracker CLI
Add storage layer with FileStorage, MarkdownReader, and MarkdownWriter classes.
Add data models (Project, Session, Note, Change).
2026-03-23 08:54:00 -03:00
59 changed files with 4959 additions and 1 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(python3:*)",
"Bash(python:*)"
]
}
}

131
.gitignore vendored Normal file
View File

@@ -0,0 +1,131 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Projects data (user-generated)
projects/*.yaml
projects/*.md
!projects/.gitkeep
# Active session file
*.active_session.json
# Temp files
*.tmp
*.bak

21
CLAUDE.md Normal file
View File

@@ -0,0 +1,21 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a personal tracker project. The codebase is currently empty (initial commit only) — no build configuration, tests, or source files exist yet.
## Development Commands
Commands will be added once the project has a build system and test setup.
## Resumen
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
- Si no existe crea una carpeta resumen en la raiz del proyecto.
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
- El archivo debe ser creado con el horario local.
## Commit
- evitar agregar lo siguiente: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

190
README.md
View File

@@ -1,2 +1,190 @@
# tracker
# Personal Tracker
Herramienta CLI para seguimiento de proyectos personales con foco en continuidad entre sesiones.
## Características
- **Persistencia simple**: Archivos Markdown y YAML legibles
- **Sin dependencias cloud**: Todo funciona offline
- **Kanban liviano**: TASKS.md con estados (Inbox, Next, Active, Blocked, Waiting, Done)
- **Bitácora por sesiones**: LOG.md y archivos de sesión detallados
- **Changelog**: Registro de cambios relevantes
- **CLI intuitiva**: Comandos para iniciar, pausar y documentar trabajo
## Stack
- Python 3.11+
- [Typer](https://typer.tiangolo.com/) - CLI
- [PyYAML](https://pyyaml.org/) - Metadatos
- [Jinja2](https://jinja.palletsprojects.com/) - Plantillas
- [Pydantic](https://docs.pydantic.dev/) - Modelos
## Instalación
### Requisitos Previos
- Python 3.11 o superior
- pip (gestor de paquetes Python)
### Método 1: Instalación con pip
```bash
# Clonar el repositorio
git clone <url-del-repositorio>
cd tracker
# Instalar en modo desarrollo
pip install -e .
# Verificar instalación
tracker --help
```
### Método 2: Usando uv (recomendado)
```bash
# Instalar con uv
uv pip install -e .
# O desde el código fuente
uv pip install -e .
```
### Verificar Instalación
```bash
tracker --help
```
Deberías ver la lista de comandos disponibles.
### Desinstalación
```bash
pip uninstall tracker
```
## Comandos
| Comando | Descripción |
|---------|-------------|
| `tracker init-project <slug>` | Crear nuevo proyecto |
| `tracker list` | Listar todos los proyectos |
| `tracker show <slug>` | Ver detalles de un proyecto |
| `tracker start <slug>` | Iniciar sesión de trabajo |
| `tracker note <text>` | Agregar nota a la sesión activa |
| `tracker stop <slug>` | Finalizar sesión y generar resumen |
| `tracker change <slug>` | Agregar entrada al changelog |
| `tracker next <slug>` | Sugerir próximos pasos |
| `tracker review` | Vista general de todos los proyectos |
| `tracker task add <slug>` | Agregar tarea a una sección |
| `tracker task move <slug>` | Mover tarea a otra sección |
## Estructura de un Proyecto
```
projects/<slug>/
├── README.md # Documentación principal
├── LOG.md # Bitácora de sesiones (append-only)
├── CHANGELOG.md # Registro de cambios
├── TASKS.md # Kanban liviano
├── sessions/ # Sesiones detalladas
├── docs/ # Documentación técnica
├── assets/ # Recursos
└── meta/
└── project.yaml # Metadatos
```
## Flujo de Uso
```bash
# 1. Crear proyecto
tracker init-project mi-proyecto --name "Mi Proyecto" --type code
# 2. Iniciar sesión
tracker start mi-proyecto --objective "Implementar feature X"
# 3. Agregar notas durante el trabajo
tracker note "Implementé la función base" --type work
tracker note "Decidí usar PostgreSQL en vez de SQLite" --type decision
# 4. Finalizar sesión
tracker stop mi-proyecto
# 5. Ver estado
tracker show mi-proyecto
tracker list
```
## Tipos de Nota
- `work` - Trabajo realizado
- `change` - Cambio realizado
- `blocker` - Bloqueo encontrado
- `decision` - Decisión tomada
- `idea` - Idea o pensamiento
- `reference` - Referencia o link
## Gestión de Tareas
```bash
# Agregar tarea a Inbox (por defecto)
tracker task add mi-proyecto "Revisar documentación de API"
# Agregar tarea a una sección específica
tracker task add mi-proyecto "Bug fix urgente" --section "Bloqueado"
# Mover tarea a otra sección
tracker task move mi-proyecto "Bug fix urgente" "En curso"
# Marcar tarea como completada (mover a Hecho)
tracker task move mi-proyecto "Bug fix urgente" "Hecho"
```
**Secciones disponibles:** Inbox, Próximo, En curso, Bloqueado, En espera, Hecho
## Estados de Proyecto
- `inbox` - Recibido/revisar
- `next` - Próximo a trabajar
- `active` - En desarrollo activo
- `blocked` - Bloqueado
- `waiting` - En espera
- `done` - Completado
- `archived` - Archivado
## Tipos de Proyecto
- `code` - Desarrollo de software
- `homelab` - Infraestructura personal
- `automation` - Automatización
- `agent` - Agentes/IA
- `research` - Investigación
- `misc` - Misceláneo
## Proyecto Demo
Ver `examples/demo-project/` para un proyecto de ejemplo completo.
## Tests
```bash
pytest tests/ -v
```
## Desarrollo
```bash
# Estructura del paquete
tracker/
├── cli/ # Comandos CLI
├── models/ # Modelos de datos
├── services/ # Lógica de negocio
├── storage/ # Persistencia archivos
├── utils/ # Utilidades
└── templates/ # Plantillas
```
## Licencia
MIT

246
backlog/mvp-1.md Normal file
View File

@@ -0,0 +1,246 @@
Construye un MVP v1 de un sistema local de seguimiento de proyectos personales.
Objetivo:
Crear una herramienta CLI que opere sobre archivos Markdown y YAML para ayudar a seguir proyectos personales con foco en continuidad entre sesiones. El sistema combina Kanban liviano, documentación viva, changelog y bitácora técnica por sesiones.
Restricciones obligatorias:
- usar persistencia en archivos legibles
- Markdown como formato principal
- YAML/JSON simple para metadatos
- sin base de datos
- sin interfaz web
- sin dependencias cloud
- todo debe seguir siendo usable manualmente aunque la CLI no exista
- una sola sesión activa a la vez en v1
- LOG.md append-only
- README.md solo puede modificarse en secciones autogeneradas delimitadas por marcadores AUTOGEN
Stack preferido:
- Python 3.11+
- Typer
- Jinja2
- PyYAML
- GitPython opcional
Estructura del repositorio esperada:
- README.md principal
- pyproject.toml
- tracker.yaml
- paquete tracker/ con cli, services, models, storage, utils y templates
- carpeta projects/
- carpeta tests/
- carpeta examples/
Cada proyecto debe generarse así:
projects/<slug>/
README.md
LOG.md
CHANGELOG.md
TASKS.md
sessions/
docs/
assets/
meta/project.yaml
Comandos mínimos a implementar:
- tracker init-project
- tracker list
- tracker show <slug>
- tracker start <slug>
- tracker note <text>
- tracker stop <slug>
- tracker change <slug>
- tracker next <slug>
- tracker review
Comportamiento requerido:
1. init-project
- crea estructura de carpetas
- genera README.md, LOG.md, CHANGELOG.md, TASKS.md y meta/project.yaml desde plantillas
- acepta name, type, tags, repo-path y description
2. list
- muestra nombre, slug, estado, última sesión y próximo paso si existe
3. show <slug>
- muestra estado actual, contexto, último resumen, bloqueos, próximos pasos y última actividad
4. start <slug>
- valida que no exista otra sesión activa
- registra hora de inicio
- crea sesión activa en un archivo tipo .active_session.json
- puede aceptar objective
- muestra contexto reciente
5. note <text>
- agrega nota a la sesión activa
- soporta tipos: work, change, blocker, decision, idea, reference
6. stop <slug>
- registra fin
- calcula duración
- consolida notas
- genera resumen heurístico
- sugiere próximos pasos
- crea archivo detallado en sessions/YYYY-MM-DD_HHMM.md
- actualiza LOG.md
- actualiza README.md en bloques AUTOGEN
- opcionalmente actualiza CHANGELOG.md
- limpia sesión activa
7. change <slug>
- añade una entrada manual al CHANGELOG.md
8. next <slug>
- sugiere próximos pasos por reglas simples, sin IA obligatoria
9. review
- muestra proyectos activos, últimas sesiones, bloqueos abiertos y proyectos sin actividad reciente
Modelo mínimo:
Proyecto:
- id
- name
- slug
- description
- type
- status
- tags
- root_path
- repo_path
- created_at
- updated_at
- last_session_at
Sesión:
- id
- project_slug
- started_at
- ended_at
- duration_minutes
- objective
- summary
- work_done
- changes
- decisions
- blockers
- next_steps
- references
- raw_notes
Cambio:
- date
- type
- title
- impact
- references
Valores sugeridos de type:
- code
- homelab
- automation
- agent
- research
- misc
Valores sugeridos de status:
- inbox
- next
- active
- blocked
- waiting
- done
- archived
README.md del proyecto debe incluir:
- nombre
- descripción
- objetivo
- estado actual
- contexto actual
- stack / herramientas
- arquitectura breve
- decisiones técnicas
- riesgos / bloqueos
- próximos pasos
- últimas sesiones
Usa bloques delimitados, por ejemplo:
<!-- AUTOGEN:STATUS_START -->
...
<!-- AUTOGEN:STATUS_END -->
LOG.md debe ser append-only y usar entradas como:
## 2026-03-23 10:0011:20
**Objetivo**
...
**Trabajo realizado**
- ...
**Cambios relevantes**
- ...
**Bloqueos**
- ...
**Decisiones**
- ...
**Próximos pasos**
- ...
**Resumen**
...
CHANGELOG.md debe registrar solo cambios relevantes:
- code
- infra
- config
- docs
- automation
- decision
TASKS.md debe tener secciones:
- Inbox
- Próximo
- En curso
- Bloqueado
- En espera
- Hecho
Heurísticas mínimas:
- si hay bloqueos abiertos, priorizar destrabar
- si hubo cambios sin validación, sugerir validar
- si hubo trabajo parcial, sugerir cerrar el hilo abierto
- si no hubo avances, sugerir redefinir objetivo
- si hubo commits recientes no documentados, sugerir registrarlos
Integración Git:
- opcional
- leer commits recientes si repo_path existe
- no hacer commit, push ni cambios de ramas
Requisitos de calidad:
- código modular
- mensajes de error claros
- paths multiplataforma
- UTF-8
- tests básicos del flujo principal
Entregables:
- estructura completa del repositorio
- CLI funcional
- plantillas base
- README principal
- ejemplos de uso
- tests básicos
- proyecto demo
Prioriza claridad, mantenibilidad y bajo acoplamiento.
Además:
- implementa primero la estructura y los comandos base
- evita sobreingeniería
- usa funciones pequeñas y testeables
- separa claramente CLI, lógica de dominio y persistencia
- no escondas datos importantes en formatos opacos
- añade ejemplos de salida de comandos
- incluye un proyecto demo ya generado
- incluye tests del flujo init-project → start → note → stop → show

0
docs/.gitkeep Normal file
View File

0
examples/.gitkeep Normal file
View File

View File

@@ -0,0 +1,33 @@
# Changelog - Demo Project
## [0.1.1] - 2026-03-23
### Agregado
- Documentacion de la API de exportacion en `docs/api-export.md`
- Tests de integracion para el modulo exporter
### Modificado
- Actualizado el formato de metadatos de proyecto para incluir `last_session_at`
- Mejorado el parseo de fechas en sesiones
## [0.1.0] - 2026-03-22
### Agregado
- Modulo de exportacion con soporte para JSON
- Estructura de sesiones con metadatos en YAML front-matter
- Archivo `TASKS.md` con seguimiento de tareas
### Modificado
- Refactorizacion de `project.yaml` para incluir `slug` y `type`
## [0.0.1] - 2026-03-20
### Agregado
- Estructura inicial del proyecto demo
- Metadatos basicos en `meta/project.yaml`
- Primera sesion de planificación

View File

@@ -0,0 +1,59 @@
# Registro de Sesiones - Demo Project
## Sesion: 2026-03-22 14:30
**Duracion:** 2h 15min
**Objetivo:** Implementar funcionalidad de exportacion
### Trabajo realizado
- Analice los requisitos para la exportacion a JSON y CSV
- Diseñe la estructura de datos para los formatos de exportacion
- Implemente la funcion base de exportacion en `exporter.py`
- Escribi pruebas unitarias para los formateadores
### Bloqueos
- None
### Decisiones
- Decidi usar una clase base `BaseExporter` con métodos abstractos para cada formato
- La estructura de directorios sigue el patrón `YYYY-MM-DD_HHMM.md`
### Proximos pasos
- [ ] Implementar exportacion a CSV
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
- [ ] Documentar la API de exportacion
---
## Sesion: 2026-03-20 10:00
**Duracion:** 1h 30min
**Objetivo:** Iniciar proyecto demo
### Trabajo realizado
- Cree la estructura inicial del proyecto demo
- Defini los metadatos base en `meta/project.yaml`
- Configure el sistema de seguimiento basico
- Revise la documentacion del tracker CLI
### Bloqueos
- Ninguno
### Decisiones
- El proyecto usara el tipo "misc" para mayor flexibilidad
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
### Proximos pasos
- [ ] Definir las primeras tareas del proyecto
- [ ] Crear la estructura de documentacion
- [ ] Establecer el flujo de trabajo regular
---

View File

@@ -0,0 +1,21 @@
# Demo Project
## Descripcion
Proyecto de demostración del tracker personal para mostrar las capacidades del sistema de seguimiento de tareas y sesiones.
## Estado
- **Estado:** Activo
- **Tipo:** Misc
- **Etiquetas:** demo, example
## Secciones
- [Tareas](./TASKS.md) - Lista de tareas del proyecto
- [Registro de sesiones](./LOG.md) - Historial de sesiones de trabajo
- [Cambios](./CHANGELOG.md) - Registro de cambios realizados
---
*Ultima actualizacion: 2026-03-23T12:00:00*

View File

@@ -0,0 +1,31 @@
# Tareas - Demo Project
## Inbox
- [ ] Revisar feedback del usuario sobre la interfaz de exportacion
- [ ] Investigar opciones para generar reportes en PDF
## Next
- [ ] Implementar exportacion a CSV
- [ ] Agregar filtros por fecha en el listado de tareas
- [ ] Crear plantilla para reportes semanales
## Active
- [ ] Implementar exportacion a CSV (iniciado 2026-03-22)
## Blocked
- [ ] Integracion con API de terceros (pendiente credenciales)
## Waiting
- [ ] Revision de codigo por parte del equipo (esperando feedback)
## Done
- [x] Crear estructura inicial del proyecto - 2026-03-20
- [x] Implementar exportacion a JSON - 2026-03-22
- [x] Escribir pruebas unitarias - 2026-03-22
- [x] Documentar formato de sesiones - 2026-03-21

View File

View File

View File

@@ -0,0 +1,13 @@
id: demo-001
name: Demo Project
slug: demo-project
description: Proyecto de demostración del tracker personal
type: misc
status: active
tags:
- demo
- example
repo_path: null
created_at: "2026-03-20T10:00:00"
updated_at: "2026-03-23T12:00:00"
last_session_at: "2026-03-22T14:30:00"

View File

@@ -0,0 +1,53 @@
---
date: "2026-03-20T10:00:00"
duration: 90
objective: "Iniciar proyecto demo"
project: demo-001
---
# Sesion: 2026-03-20 10:00
## Objetivo
Iniciar el proyecto demo para demostrar las capacidades del tracker personal.
## Notas
### planeacion
Necesito crear un proyecto que sirva como ejemplo real de uso del sistema.
Debe incluir:
- Estructura de directorios completa
- Metadatos realistas
- Sesiones de trabajo con contenido genuino
- Tareas en diferentes estados
### observaciones
El sistema de tracking funciona bien para proyectos personales.
La estructura de sesiones con front-matter YAML es flexible y util.
Me gusta poder vincular sesiones a proyectos especificos.
## Trabajo realizado
- [x] Cree la estructura inicial del proyecto demo
- [x] Defini los metadatos base en `meta/project.yaml`
- [x] Configure el sistema de seguimiento basico
- [x] Revise la documentacion del tracker CLI
## Bloqueos
Ninguno.
## Decisiones
- El proyecto usara el tipo "misc" para mayor flexibilidad
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
- El ID del proyecto sera "demo-001" para facilitar la referencia
## Proximos pasos
- [ ] Definir las primeras tareas del proyecto
- [ ] Crear la estructura de documentacion
- [ ] Establecer el flujo de trabajo regular

View File

@@ -0,0 +1,75 @@
---
date: "2026-03-22T14:30:00"
duration: 135
objective: "Implementar funcionalidad de exportacion"
project: demo-001
---
# Sesion: 2026-03-22 14:30
## Objetivo
Implementar la funcionalidad de exportacion de datos del proyecto a diferentes formatos.
## Notas
### investigacion
Analice los requisitos para la exportacion:
**JSON:**
- Estructura jerarquica con metadatos completos
- Incluye timestamps en formato ISO
- Representacion de tareas por estado
**CSV:**
- Formato plano para importacion a spreadsheets
- Headers: id, titulo, estado, fecha_creacion, fecha_completado
- UTF-8 encoding
### diseno
Decidi usar una clase base `BaseExporter` con:
```
BaseExporter
├── export(data) -> str
├── format_metadata(meta) -> dict
└── validate_data(data) -> bool
JsonExporter(BaseExporter)
CsvExporter(BaseExporter)
```
### codigo
Escribi la implementacion inicial del exporter:
```python
class BaseExporter:
def export(self, data: dict) -> str:
raise NotImplementedError
```
## Trabajo realizado
- [x] Analice los requisitos para la exportacion a JSON y CSV
- [x] Diseñe la estructura de datos para los formatos de exportacion
- [x] Implemente la funcion base de exportacion en `exporter.py`
- [x] Escribi pruebas unitarias para los formateadores
## Bloqueos
Ninguno.
## Decisiones
- Decidi usar una clase base `BaseExporter` con metodos abstractos para cada formato
- La estructura de directorios sigue el patron `YYYY-MM-DD_HHMM.md`
- Los archivos de sesion incluyen front-matter con metadatos estructurados
## Proximos pasos
- [ ] Implementar exportacion a CSV
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
- [ ] Documentar la API de exportacion

0
projects/.gitkeep Normal file
View File

View File

@@ -0,0 +1,4 @@
# Changelog
_Project changes_

View File

@@ -0,0 +1,50 @@
# Log
_Project activity log_
## 2026-03-24 23:4000:07
**Objetivo**
No especificado
**Trabajo realizado**
- Sin trabajo registrado
**Cambios relevantes**
- Sin cambios
**Bloqueos**
- Sin bloqueos
**Decisiones**
- Sin decisiones
**Próximos pasos**
- Definir próximos pasos
**Resumen**
Session de 27 minutos sin progreso registrado.
## 2026-03-25 00:0900:13
**Objetivo**
No especificado
**Trabajo realizado**
- Sin trabajo registrado
**Cambios relevantes**
- Sin cambios
**Bloqueos**
- Sin bloqueos
**Decisiones**
- Sin decisiones
**Próximos pasos**
- Definir próximos pasos
**Resumen**
Session de 3 minutos sin progreso registrado.

View File

@@ -0,0 +1,50 @@
# mi-proyecto
_No description_
## Objective
_TODO: Define objective_
## Status
**Current Status:** inbox
<!-- AUTOGEN:STATUS_START -->
Status: inbox
<!-- AUTOGEN:STATUS_END -->
## Context
_Current context and background_
## Stack / Tools
- _Tool 1_
- _Tool 2_
## Architecture
_Brief architecture description_
## Technical Decisions
_No decisions recorded yet_
## Risks / Blockers
_No blockers_
<!-- AUTOGEN:NEXT_STEPS_START -->
- Definir próximos pasos
- Definir próximos pasos
<!-- AUTOGEN:NEXT_STEPS_END -->
## Recent Sessions
<!-- AUTOGEN:SESSIONS_START -->
- 2026-03-24 23:40 (27 min): Session de 27 minutos sin progreso registrado....
- 2026-03-25 00:09 (3 min): Session de 3 minutos sin progreso registrado....
<!-- AUTOGEN:SESSIONS_END -->
_Last updated: 2026-03-24_

View File

@@ -0,0 +1,20 @@
# Tasks
## Inbox
-
## Próximo
- [ ] Test task 1
## En curso
-
## Bloqueado
-
## En espera
-
## Hecho
- [x] Test task 2
- [x] New test task

View File

@@ -0,0 +1,12 @@
id: d7443ae2-9d23-4dc9-85c2-50437d6ab993
name: mi-proyecto
slug: mi-proyecto
description: ''
type: misc
status: inbox
tags: []
root_path: projects/mi-proyecto
repo_path: null
created_at: '2026-03-24T23:39:35.125241'
updated_at: '2026-03-24T23:39:35.125246'
last_session_at: null

View File

@@ -0,0 +1,28 @@
# Sesion: 2026-03-24 23:4000:07
## Objetivo
No especificado
## Notas
- Sin notas
## Trabajo realizado
- Sin trabajo realizado
## Cambios
- Sin cambios
## Decisiones
- Sin decisiones
## Bloqueos
- Sin bloqueos
## Proximos pasos
- Definir próximos pasos
## Referencias
- Sin referencias
## Duracion
27 minutos

View File

@@ -0,0 +1,28 @@
# Sesion: 2026-03-25 00:0900:13
## Objetivo
No especificado
## Notas
- [idea] prueba de idea
## Trabajo realizado
- Sin trabajo realizado
## Cambios
- Sin cambios
## Decisiones
- Sin decisiones
## Bloqueos
- Sin bloqueos
## Proximos pasos
- Definir próximos pasos
## Referencias
- Sin referencias
## Duracion
3 minutos

View File

@@ -0,0 +1,18 @@
{
"id": "5f6b1f86-7ce1-4079-b886-9885cff9cca5",
"project_slug": "mi-proyecto",
"started_at": "2026-03-24T23:40:18.747800",
"ended_at": "2026-03-25T00:07:23.886040",
"duration_minutes": 27,
"objective": "",
"summary": "Session de 27 minutos sin progreso registrado.",
"work_done": [],
"changes": [],
"decisions": [],
"blockers": [],
"next_steps": [
"Definir próximos pasos"
],
"references": [],
"raw_notes": []
}

View File

@@ -0,0 +1,24 @@
{
"id": "6103e2ec-e40c-4a0b-8672-cd4db18ca487",
"project_slug": "mi-proyecto",
"started_at": "2026-03-25T00:09:38.396081",
"ended_at": "2026-03-25T00:13:11.888662",
"duration_minutes": 3,
"objective": "",
"summary": "Session de 3 minutos sin progreso registrado.",
"work_done": [],
"changes": [],
"decisions": [],
"blockers": [],
"next_steps": [
"Definir próximos pasos"
],
"references": [],
"raw_notes": [
{
"type": "idea",
"text": "prueba de idea",
"created_at": "2026-03-25T00:11:32.741023"
}
]
}

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[project]
name = "tracker"
version = "0.1.0"
description = "Personal project tracker CLI with Markdown-based persistence"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"typer[all]>=0.12.0",
"pyyaml>=6.0",
"jinja2>=3.1.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
git = ["gitpython"]
[project.scripts]
tracker = "tracker.cli.main:app"
[tool.setuptools.packages.find]
where = ["."]
include = ["tracker*"]
exclude = ["tests*", "examples*", "docs*", "backlog*", "resumen*"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,85 @@
# Resumen del Proyecto: Personal Tracker CLI
## Fecha: 2026-03-24 12:45
## Descripción General
**Personal Tracker** es una herramienta CLI para seguimiento de proyectos personales, enfocada en la continuidad entre sesiones de trabajo. Utiliza archivos Markdown y YAML como persistencia, sin base de datos ni dependencias cloud.
## Rama Actual
- **develop**
## Stack Tecnológico
- Python 3.11+
- Typer (CLI)
- PyYAML (metadatos)
- Jinja2 (plantillas)
- Pydantic (modelos)
- GitPython (opcional)
## Estructura del Proyecto
```
tracker/
├── cli/ # Comandos CLI (Typer)
├── models/ # Modelos de datos (Pydantic)
├── services/ # Lógica de negocio
├── storage/ # Persistencia archivos
├── utils/ # Utilidades
└── templates/ # Plantillas Jinja2
projects/ # Proyectos creados
tests/ # 72 tests
examples/demo-project/ # Proyecto demo
backlog/ # Requisitos (MVP-1.md)
```
## Comandos Implementados
| Comando | Descripción |
|---------|-------------|
| `init-project` | Crear nuevo proyecto |
| `list` | Listar proyectos |
| `show` | Ver detalles |
| `start` | Iniciar sesión |
| `note` | Agregar nota |
| `stop` | Finalizar sesión |
| `change` | Agregar al changelog |
| `next` | Sugerir próximos pasos |
| `review` | Vista general |
## Commits en Rama develop
1. `525996f` - Initial commit
2. `4547c49` - Implement storage layer
3. `b0c65a0` - Implement core services
4. `88a474a` - Implement CLI commands
5. `40a33d7` - Implement project templates
6. `b36b603` - Complete CLI commands
7. `4e67062` - Add demo project
8. `2735562` - Add comprehensive tests
9. `4d99213` - Update README with MVP-1 documentation
10. `b4593c6` - Add detailed installation guide
11. `cc52360` - Fix pyproject.toml package discovery
## Instalación
```bash
pip install -e .
```
## Tests
```bash
pytest tests/ -v
# 72 tests implementados
```
## Estado Actual
- MVP-1 implementado y funcional
- CLI operativa con todos los comandos
- Tests cubiertos
- Documentación completa en README.md

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests package."""

102
tests/conftest.py Normal file
View File

@@ -0,0 +1,102 @@
"""Pytest fixtures for tracker tests."""
import json
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import pytest
from tracker.models import Project, Session, Note, NoteType, Change
@pytest.fixture
def tmp_project_dir(tmp_path):
"""Create a temporary directory for project tests."""
projects_root = tmp_path / "projects"
projects_root.mkdir()
return projects_root
@pytest.fixture
def sample_project_data():
"""Sample project data for testing."""
return {
"id": "test-project-id-123",
"name": "Test Project",
"slug": "test-project",
"description": "A test project for unit testing",
"type": "code",
"status": "active",
"tags": ["python", "testing"],
"root_path": "/path/to/projects/test-project",
"repo_path": "/path/to/repos/test-project",
"created_at": datetime(2024, 1, 15, 10, 0, 0),
"updated_at": datetime(2024, 1, 15, 10, 0, 0),
"last_session_at": None,
}
@pytest.fixture
def sample_project(sample_project_data):
"""Create a sample Project instance."""
return Project(**sample_project_data)
@pytest.fixture
def mock_session(sample_project):
"""Create a mock session for testing."""
return Session(
id="session-123",
project_slug=sample_project.slug,
started_at=datetime(2024, 1, 15, 10, 0, 0),
ended_at=datetime(2024, 1, 15, 11, 30, 0),
duration_minutes=90,
objective="Complete initial implementation",
summary="Worked on core features",
work_done=["Implemented feature A", "Fixed bug B"],
changes=["Added new endpoint"],
decisions=["Use JSON for storage"],
blockers=[],
next_steps=["Add tests", "Write documentation"],
references=["https://example.com/docs"],
raw_notes=[
{"type": "work", "text": "Working on feature A", "timestamp": "2024-01-15T10:15:00"},
{"type": "idea", "text": "Consider using caching", "timestamp": "2024-01-15T10:30:00"},
],
)
@pytest.fixture
def sample_note():
"""Create a sample note for testing."""
return Note(
type=NoteType.WORK,
text="Completed the implementation of feature X",
created_at=datetime(2024, 1, 15, 10, 30, 0),
)
@pytest.fixture
def sample_change():
"""Create a sample change for testing."""
return Change(
date=datetime(2024, 1, 15).date(),
type="code",
title="Added user authentication",
impact="Improved security",
references=["#123"],
)
@pytest.fixture
def tmp_project_with_structure(tmp_project_dir, sample_project_data):
"""Create a temporary project with directory structure."""
slug = sample_project_data["slug"]
project_root = tmp_project_dir / slug
(project_root / "sessions").mkdir(parents=True)
(project_root / "docs").mkdir(parents=True)
(project_root / "assets").mkdir(parents=True)
(project_root / "meta").mkdir(parents=True)
return project_root

337
tests/test_flow.py Normal file
View File

@@ -0,0 +1,337 @@
"""Tests for complete flow: init -> start -> note -> stop -> show."""
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from tracker.models import Session, Note, NoteType
from tracker.services import (
create_project,
get_project,
ensure_project_structure,
set_active_session,
get_active_session,
clear_active_session,
validate_no_other_active_session,
add_note,
consolidate_notes,
get_projects_root,
)
from tracker.storage import FileStorage
class TestInitProjectFlow:
"""Tests for project initialization flow."""
def test_init_project_creates_structure(self, tmp_path, sample_project_data):
"""Test that project initialization creates required directory structure."""
slug = sample_project_data["slug"]
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
description=sample_project_data["description"],
type=sample_project_data["type"],
)
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").is_dir()
assert (project_root / "docs").is_dir()
assert (project_root / "assets").is_dir()
assert (project_root / "meta").is_dir()
def test_init_project_creates_meta_file(self, tmp_path, sample_project_data):
"""Test that project initialization creates meta/project.yaml."""
slug = sample_project_data["slug"]
storage = FileStorage(tmp_path)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
description=sample_project_data["description"],
type=sample_project_data["type"],
)
# Write the project meta (simulating what storage would do)
meta_data = {
"id": project.id,
"name": project.name,
"slug": project.slug,
"description": project.description,
"type": project.type,
"status": project.status,
"tags": project.tags,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat(),
}
storage.write_project_meta(slug, meta_data)
meta_path = tmp_path / slug / "meta" / "project.yaml"
assert meta_path.exists()
with open(meta_path, "r") as f:
data = yaml.safe_load(f)
assert data["name"] == sample_project_data["name"]
assert data["slug"] == slug
class TestStartSessionFlow:
"""Tests for starting a session."""
def test_start_session(self, tmp_path, sample_project_data, monkeypatch):
"""Test starting a session creates active session."""
slug = sample_project_data["slug"]
# Create a fake active session path
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
)
ensure_project_structure(slug)
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
objective="Test objective",
)
set_active_session(session)
active = get_active_session()
assert active is not None
assert active.id == "session-1"
assert active.project_slug == slug
def test_start_session_fails_if_other_active(self, tmp_path, sample_project_data, monkeypatch):
"""Test that starting a session fails if another project has an active session."""
slug1 = "project-1"
slug2 = "project-2"
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session1 = Session(
id="session-1",
project_slug=slug1,
started_at=datetime.now(),
)
set_active_session(session1)
is_valid = True
if not validate_no_other_active_session(slug2):
is_valid = False
assert is_valid is False
class TestAddNoteFlow:
"""Tests for adding notes during a session."""
def test_add_note(self, tmp_path, sample_project_data, monkeypatch):
"""Test adding a note during a session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
set_active_session(session)
add_note(session, "work", "Test work note")
assert len(session.raw_notes) == 1
assert session.raw_notes[0]["type"] == "work"
assert session.raw_notes[0]["text"] == "Test work note"
def test_add_multiple_notes(self, tmp_path, sample_project_data, monkeypatch):
"""Test adding multiple notes during a session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
add_note(session, "work", "Work note")
add_note(session, "idea", "Idea note")
add_note(session, "blocker", "Blocker note")
assert len(session.raw_notes) == 3
assert session.raw_notes[0]["type"] == "work"
assert session.raw_notes[1]["type"] == "idea"
assert session.raw_notes[2]["type"] == "blocker"
class TestStopSessionFlow:
"""Tests for stopping a session."""
def test_stop_session(self, tmp_path, sample_project_data, monkeypatch):
"""Test stopping a session clears active session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
set_active_session(session)
session.ended_at = datetime.now()
session.duration_minutes = 30
clear_active_session()
active = get_active_session()
assert active is None
class TestShowProjectFlow:
"""Tests for showing project information."""
def test_show_project(self, tmp_path, sample_project_data):
"""Test showing project information."""
slug = sample_project_data["slug"]
storage = FileStorage(tmp_path)
meta_data = {
"id": "test-id",
"name": sample_project_data["name"],
"slug": slug,
"description": sample_project_data["description"],
"type": sample_project_data["type"],
"status": sample_project_data["status"],
"tags": sample_project_data["tags"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
storage.write_project_meta(slug, meta_data)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = get_project(slug)
assert project is not None
assert project.name == sample_project_data["name"]
assert project.slug == slug
class TestConsolidateNotes:
"""Tests for consolidating notes after session."""
def test_consolidate_notes_categorizes_work(self):
"""Test that consolidate_notes categorizes work notes."""
raw_notes = [
{"type": "work", "text": "Implemented feature A", "timestamp": "2024-01-15T10:00:00"},
{"type": "work", "text": "Wrote tests", "timestamp": "2024-01-15T10:30:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["work_done"]) == 2
assert "Implemented feature A" in consolidated["work_done"]
assert "Wrote tests" in consolidated["work_done"]
def test_consolidate_notes_categorizes_blockers(self):
"""Test that consolidate_notes categorizes blockers."""
raw_notes = [
{"type": "blocker", "text": "Waiting for API access", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["blockers"]) == 1
assert "Waiting for API access" in consolidated["blockers"]
def test_consolidate_notes_categorizes_decisions(self):
"""Test that consolidate_notes categorizes decisions."""
raw_notes = [
{"type": "decision", "text": "Use PostgreSQL for storage", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["decisions"]) == 1
assert "Use PostgreSQL for storage" in consolidated["decisions"]
def test_consolidate_notes_categorizes_references(self):
"""Test that consolidate_notes categorizes references."""
raw_notes = [
{"type": "reference", "text": "https://docs.example.com", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["references"]) == 1
assert "https://docs.example.com" in consolidated["references"]
def test_consolidate_notes_categorizes_changes(self):
"""Test that consolidate_notes categorizes changes."""
raw_notes = [
{"type": "change", "text": "Refactored authentication module", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["changes"]) == 1
assert "Refactored authentication module" in consolidated["changes"]

220
tests/test_models.py Normal file
View File

@@ -0,0 +1,220 @@
"""Tests for data models."""
from datetime import datetime, date
import pytest
from tracker.models import Project, Session, Note, NoteType, Change
class TestProjectCreation:
"""Tests for Project model creation."""
def test_project_creation_with_all_fields(self, sample_project_data):
"""Test creating a project with all fields specified."""
project = Project(**sample_project_data)
assert project.id == sample_project_data["id"]
assert project.name == sample_project_data["name"]
assert project.slug == sample_project_data["slug"]
assert project.description == sample_project_data["description"]
assert project.type == sample_project_data["type"]
assert project.status == sample_project_data["status"]
assert project.tags == sample_project_data["tags"]
assert project.root_path == sample_project_data["root_path"]
assert project.repo_path == sample_project_data["repo_path"]
assert project.created_at == sample_project_data["created_at"]
assert project.updated_at == sample_project_data["updated_at"]
assert project.last_session_at is None
def test_project_creation_with_defaults(self):
"""Test creating a project with default values."""
now = datetime.now()
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=now,
updated_at=now,
)
assert project.description == ""
assert project.type == "misc"
assert project.status == "inbox"
assert project.tags == []
assert project.root_path == ""
assert project.repo_path is None
assert project.last_session_at is None
def test_project_status_validation(self):
"""Test that project status can be set to valid values."""
valid_statuses = ["inbox", "next", "active", "blocked", "waiting", "done", "archived"]
for status in valid_statuses:
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=datetime.now(),
updated_at=datetime.now(),
status=status,
)
assert project.status == status
def test_project_type_validation(self):
"""Test that project type can be set to valid values."""
valid_types = ["code", "homelab", "automation", "agent", "research", "misc"]
for project_type in valid_types:
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=datetime.now(),
updated_at=datetime.now(),
type=project_type,
)
assert project.type == project_type
class TestSessionCreation:
"""Tests for Session model creation."""
def test_session_creation_with_all_fields(self, mock_session):
"""Test creating a session with all fields specified."""
assert mock_session.id == "session-123"
assert mock_session.project_slug == "test-project"
assert mock_session.started_at == datetime(2024, 1, 15, 10, 0, 0)
assert mock_session.ended_at == datetime(2024, 1, 15, 11, 30, 0)
assert mock_session.duration_minutes == 90
assert mock_session.objective == "Complete initial implementation"
assert mock_session.summary == "Worked on core features"
assert len(mock_session.work_done) == 2
assert len(mock_session.changes) == 1
assert len(mock_session.decisions) == 1
assert len(mock_session.blockers) == 0
assert len(mock_session.next_steps) == 2
assert len(mock_session.references) == 1
assert len(mock_session.raw_notes) == 2
def test_session_creation_with_defaults(self):
"""Test creating a session with required fields only."""
session = Session(
id="session-1",
project_slug="test-project",
started_at=datetime.now(),
)
assert session.ended_at is None
assert session.duration_minutes is None
assert session.objective == ""
assert session.summary == ""
assert session.work_done == []
assert session.changes == []
assert session.decisions == []
assert session.blockers == []
assert session.next_steps == []
assert session.references == []
assert session.raw_notes == []
def test_session_with_optional_datetime_fields(self):
"""Test session with ended_at but no duration_minutes."""
session = Session(
id="session-1",
project_slug="test-project",
started_at=datetime(2024, 1, 15, 10, 0, 0),
ended_at=datetime(2024, 1, 15, 11, 0, 0),
)
assert session.ended_at is not None
assert session.duration_minutes is None
class TestNoteTypes:
"""Tests for Note and NoteType models."""
def test_note_creation(self, sample_note):
"""Test creating a note."""
assert sample_note.type == NoteType.WORK
assert sample_note.text == "Completed the implementation of feature X"
assert sample_note.created_at == datetime(2024, 1, 15, 10, 30, 0)
def test_note_type_enum_values(self):
"""Test that NoteType enum has expected values."""
assert NoteType.WORK.value == "work"
assert NoteType.CHANGE.value == "change"
assert NoteType.BLOCKER.value == "blocker"
assert NoteType.DECISION.value == "decision"
assert NoteType.IDEA.value == "idea"
assert NoteType.REFERENCE.value == "reference"
def test_note_type_assignment(self):
"""Test assigning different note types to a note."""
for note_type in NoteType:
note = Note(type=note_type, text="Test note")
assert note.type == note_type
def test_note_default_created_at(self):
"""Test that note has default created_at timestamp."""
note = Note(type=NoteType.WORK, text="Test note")
assert note.created_at is not None
assert isinstance(note.created_at, datetime)
def test_note_raw_notes_structure(self, mock_session):
"""Test that raw_notes in session have expected structure."""
assert len(mock_session.raw_notes) == 2
assert mock_session.raw_notes[0]["type"] == "work"
assert mock_session.raw_notes[0]["text"] == "Working on feature A"
assert "timestamp" in mock_session.raw_notes[0]
class TestChangeValidation:
"""Tests for Change model validation."""
def test_change_creation(self, sample_change):
"""Test creating a change."""
assert sample_change.date == date(2024, 1, 15)
assert sample_change.type == "code"
assert sample_change.title == "Added user authentication"
assert sample_change.impact == "Improved security"
assert sample_change.references == ["#123"]
def test_change_creation_with_defaults(self):
"""Test creating a change with default values."""
change = Change(
date=date(2024, 1, 15),
type="docs",
title="Updated documentation",
)
assert change.impact == ""
assert change.references == []
def test_change_date_as_date_not_datetime(self, sample_change):
"""Test that change date is stored as date object."""
assert isinstance(sample_change.date, date)
assert not isinstance(sample_change.date, datetime)
def test_change_types(self):
"""Test valid change types."""
valid_types = ["code", "infra", "config", "docs", "automation", "decision"]
for change_type in valid_types:
change = Change(
date=date.today(),
type=change_type,
title=f"Test {change_type}",
)
assert change.type == change_type
def test_change_with_multiple_references(self):
"""Test change with multiple references."""
change = Change(
date=date.today(),
type="code",
title="Major refactor",
references=["#100", "#101", "#102"],
)
assert len(change.references) == 3
assert "#100" in change.references
assert "#102" in change.references

View File

@@ -0,0 +1,209 @@
"""Tests for ProjectService."""
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from tracker.models import Project
from tracker.services import (
create_project,
get_project,
update_project,
list_projects,
get_projects_root,
ensure_project_structure,
)
class TestCreateProject:
"""Tests for create_project function."""
def test_create_project_returns_project_instance(self):
"""Test that create_project returns a Project instance."""
project = create_project(
name="Test Project",
slug="test-project",
description="A test project",
type="code",
)
assert isinstance(project, Project)
assert project.name == "Test Project"
assert project.slug == "test-project"
assert project.description == "A test project"
assert project.type == "code"
def test_create_project_generates_id(self):
"""Test that create_project generates a UUID."""
project = create_project(name="Test", slug="test")
assert project.id is not None
assert len(project.id) > 0
def test_create_project_sets_default_status(self):
"""Test that create_project sets default status to inbox."""
project = create_project(name="Test", slug="test")
assert project.status == "inbox"
def test_create_project_sets_timestamps(self):
"""Test that create_project sets created_at and updated_at."""
before = datetime.now()
project = create_project(name="Test", slug="test")
after = datetime.now()
assert before <= project.created_at <= after
assert before <= project.updated_at <= after
# created_at and updated_at should be very close (within 1 second)
time_diff = abs((project.updated_at - project.created_at).total_seconds())
assert time_diff < 1
def test_create_project_with_tags(self):
"""Test creating a project with tags."""
tags = ["python", "testing", "cli"]
project = create_project(name="Test", slug="test", tags=tags)
assert project.tags == tags
def test_create_project_with_repo_path(self):
"""Test creating a project with a repo path."""
repo_path = Path("/path/to/repo")
project = create_project(name="Test", slug="test", repo_path=repo_path)
assert project.repo_path == str(repo_path)
def test_create_project_without_repo_path(self):
"""Test creating a project without repo path."""
project = create_project(name="Test", slug="test")
assert project.repo_path is None
class TestGetProject:
"""Tests for get_project function."""
def test_get_project_returns_none_for_nonexistent(self, tmp_path):
"""Test that get_project returns None for nonexistent project."""
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
result = get_project("nonexistent")
assert result is None
def test_get_project_returns_project_when_exists(self, tmp_path, sample_project_data):
"""Test that get_project returns Project when it exists."""
slug = sample_project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(sample_project_data, f)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = get_project(slug)
assert project is not None
assert project.slug == slug
assert project.name == sample_project_data["name"]
def test_get_project_handles_invalid_yaml(self, tmp_path):
"""Test that get_project handles invalid YAML gracefully."""
slug = "test-project"
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
f.write("invalid: yaml: content:")
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
result = get_project(slug)
assert result is None
class TestListProjects:
"""Tests for list_projects function."""
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_path):
"""Test that list_projects returns empty list when no projects exist."""
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert projects == []
def test_list_projects_returns_all_projects(self, tmp_path, sample_project_data):
"""Test that list_projects returns all existing projects."""
project1_data = sample_project_data.copy()
project1_data["slug"] = "project-1"
project1_data["name"] = "Project 1"
project2_data = sample_project_data.copy()
project2_data["slug"] = "project-2"
project2_data["name"] = "Project 2"
for project_data in [project1_data, project2_data]:
slug = project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(project_data, f)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert len(projects) == 2
slugs = [p.slug for p in projects]
assert "project-1" in slugs
assert "project-2" in slugs
def test_list_projects_ignores_hidden_directories(self, tmp_path, sample_project_data):
"""Test that list_projects ignores hidden directories."""
slug = sample_project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(sample_project_data, f)
hidden_dir = tmp_path / ".hidden"
hidden_dir.mkdir()
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert len(projects) == 1
assert projects[0].slug == slug
class TestEnsureProjectStructure:
"""Tests for ensure_project_structure function."""
def test_ensure_project_structure_creates_directories(self, tmp_path):
"""Test that ensure_project_structure creates required directories."""
slug = "test-project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()
assert (project_root / "docs").exists()
assert (project_root / "assets").exists()
assert (project_root / "meta").exists()
def test_ensure_project_structure_is_idempotent(self, tmp_path):
"""Test that ensure_project_structure can be called multiple times safely."""
slug = "test-project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()
assert (project_root / "docs").exists()
def test_ensure_project_structure_creates_nested_directories(self, tmp_path):
"""Test that ensure_project_structure creates parent directories if needed."""
slug = "nested/project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()

View File

@@ -0,0 +1,241 @@
"""Tests for SessionService."""
import json
from datetime import datetime
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from tracker.models import Session
from tracker.services import (
get_active_session,
set_active_session,
clear_active_session,
get_active_session_path,
validate_no_other_active_session,
)
@pytest.fixture
def mock_active_session_path(tmp_path, monkeypatch):
"""Mock the active session path to use tmp_path."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
return fake_path
class TestSetAndGetActiveSession:
"""Tests for set and get active session functions."""
def test_set_and_get_active_session(self, tmp_path, mock_session, monkeypatch):
"""Test setting and getting an active session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
retrieved = get_active_session()
assert retrieved is not None
assert retrieved.id == mock_session.id
assert retrieved.project_slug == mock_session.project_slug
assert retrieved.started_at == mock_session.started_at
def test_get_active_session_returns_none_when_no_session(self, tmp_path, monkeypatch):
"""Test that get_active_session returns None when no session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
result = get_active_session()
assert result is None
def test_set_active_session_creates_parent_directories(self, tmp_path, mock_session, monkeypatch):
"""Test that set_active_session creates parent directories if needed."""
fake_path = tmp_path / "subdir" / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
assert fake_path.exists()
def test_active_session_contains_all_fields(self, tmp_path, mock_session, monkeypatch):
"""Test that active session file contains all session fields."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
with open(fake_path, "r") as f:
data = json.load(f)
assert data["id"] == mock_session.id
assert data["project_slug"] == mock_session.project_slug
assert "started_at" in data
assert "work_done" in data
assert data["work_done"] == mock_session.work_done
class TestClearActiveSession:
"""Tests for clear_active_session function."""
def test_clear_active_session_removes_file(self, tmp_path, mock_session, monkeypatch):
"""Test that clear_active_session removes the active session file."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
assert fake_path.exists()
clear_active_session()
assert not fake_path.exists()
def test_clear_active_session_when_no_session(self, tmp_path, monkeypatch):
"""Test that clear_active_session does not error when no session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
clear_active_session()
assert not fake_path.exists()
def test_get_active_session_after_clear(self, tmp_path, mock_session, monkeypatch):
"""Test that get_active_session returns None after clearing."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
clear_active_session()
result = get_active_session()
assert result is None
class TestValidateNoOtherActiveSession:
"""Tests for validate_no_other_active_session function."""
def test_validate_no_other_active_session_returns_true_when_none(self, tmp_path, monkeypatch):
"""Test validation returns True when no active session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
result = validate_no_other_active_session("any-project")
assert result is True
def test_validate_no_other_active_session_returns_true_for_same_project(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns True for same project's session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
result = validate_no_other_active_session(mock_session.project_slug)
assert result is True
def test_validate_no_other_active_session_returns_false_for_different_project(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns False for different project's session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
result = validate_no_other_active_session("different-project")
assert result is False
def test_validate_no_other_active_session_with_no_active_after_clear(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns True after clearing session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
clear_active_session()
result = validate_no_other_active_session("any-project")
assert result is True

193
tests/test_storage.py Normal file
View File

@@ -0,0 +1,193 @@
"""Tests for FileStorage."""
import json
from datetime import datetime
from pathlib import Path
import pytest
import yaml
from tracker.models import Session
from tracker.storage import FileStorage
class TestReadWriteProjectMeta:
"""Tests for read/write project meta operations."""
def test_write_and_read_project_meta(self, tmp_project_dir):
"""Test writing and reading project meta."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
meta_data = {
"id": "test-id",
"name": "Test Project",
"slug": slug,
"description": "A test project",
"type": "code",
"status": "active",
"tags": ["python"],
"created_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
"updated_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
}
storage.write_project_meta(slug, meta_data)
result = storage.read_project_meta(slug)
assert result["name"] == "Test Project"
assert result["slug"] == slug
assert result["type"] == "code"
def test_read_project_meta_creates_parent_directories(self, tmp_project_dir):
"""Test that write_project_meta creates parent directories."""
storage = FileStorage(tmp_project_dir)
slug = "new-project"
meta_data = {"id": "test-id", "name": "Test", "slug": slug}
storage.write_project_meta(slug, meta_data)
meta_path = tmp_project_dir / slug / "meta" / "project.yaml"
assert meta_path.exists()
def test_read_project_meta_raises_for_nonexistent(self, tmp_project_dir):
"""Test that reading nonexistent project raises error."""
storage = FileStorage(tmp_project_dir)
with pytest.raises(FileNotFoundError):
storage.read_project_meta("nonexistent")
class TestAppendToLog:
"""Tests for append_to_log operations."""
def test_append_to_log_creates_log_file(self, tmp_project_dir):
"""Test that append_to_log creates LOG.md if it doesn't exist."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
# Create project directory first
(tmp_project_dir / slug).mkdir(parents=True)
storage.append_to_log(slug, "# Test Log Entry\n")
log_path = tmp_project_dir / slug / "LOG.md"
assert log_path.exists()
def test_append_to_log_appends_content(self, tmp_project_dir):
"""Test that append_to_log appends content to LOG.md."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
# Create project directory first
(tmp_project_dir / slug).mkdir(parents=True)
storage.append_to_log(slug, "# First Entry\n")
storage.append_to_log(slug, "# Second Entry\n")
content = storage.read_log(slug)
assert "# First Entry" in content
assert "# Second Entry" in content
def test_read_log_returns_empty_string_for_nonexistent(self, tmp_project_dir):
"""Test that read_log returns empty string for nonexistent log."""
storage = FileStorage(tmp_project_dir)
result = storage.read_log("nonexistent")
assert result == ""
class TestActiveSessionStorage:
"""Tests for active session storage operations."""
def test_write_and_read_active_session(self, tmp_project_dir, mock_session):
"""Test writing and reading active session."""
storage = FileStorage(tmp_project_dir)
session_data = mock_session.model_dump(mode="json")
session_data["started_at"] = mock_session.started_at.isoformat()
if mock_session.ended_at:
session_data["ended_at"] = mock_session.ended_at.isoformat()
storage.write_active_session(session_data)
result = storage.read_active_session()
assert result is not None
assert result["id"] == mock_session.id
def test_read_active_session_returns_none_when_not_exists(self, tmp_project_dir):
"""Test that read_active_session returns None when file doesn't exist."""
storage = FileStorage(tmp_project_dir)
result = storage.read_active_session()
assert result is None
def test_delete_active_session(self, tmp_project_dir, mock_session):
"""Test deleting active session."""
storage = FileStorage(tmp_project_dir)
session_data = mock_session.model_dump(mode="json")
session_data["started_at"] = mock_session.started_at.isoformat()
storage.write_active_session(session_data)
storage.delete_active_session()
result = storage.read_active_session()
assert result is None
def test_delete_active_session_when_not_exists(self, tmp_project_dir):
"""Test deleting active session when it doesn't exist doesn't error."""
storage = FileStorage(tmp_project_dir)
storage.delete_active_session()
class TestProjectExistence:
"""Tests for project existence checks."""
def test_project_exists_returns_true_for_existing_project(self, tmp_project_dir):
"""Test that project_exists returns True for existing project."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
(tmp_project_dir / slug).mkdir()
assert storage.project_exists(slug) is True
def test_project_exists_returns_false_for_nonexistent(self, tmp_project_dir):
"""Test that project_exists returns False for nonexistent project."""
storage = FileStorage(tmp_project_dir)
assert storage.project_exists("nonexistent") is False
class TestListProjects:
"""Tests for listing projects."""
def test_list_projects_returns_all_projects(self, tmp_project_dir):
"""Test that list_projects returns all project slugs."""
storage = FileStorage(tmp_project_dir)
(tmp_project_dir / "project-1").mkdir()
(tmp_project_dir / "project-2").mkdir()
projects = storage.list_projects()
assert "project-1" in projects
assert "project-2" in projects
def test_list_projects_excludes_hidden_directories(self, tmp_project_dir):
"""Test that list_projects excludes hidden directories."""
storage = FileStorage(tmp_project_dir)
(tmp_project_dir / "project-1").mkdir()
(tmp_project_dir / ".hidden").mkdir()
projects = storage.list_projects()
assert "project-1" in projects
assert ".hidden" not in projects
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_project_dir):
"""Test that list_projects returns empty list when no projects exist."""
storage = FileStorage(tmp_project_dir)
projects = storage.list_projects()
assert projects == []

1
tracker/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Personal Tracker CLI - A Markdown-based project tracking system."""

1
tracker/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""CLI commands for the tracker."""

898
tracker/cli/commands.py Normal file
View File

@@ -0,0 +1,898 @@
"""CLI commands implementation."""
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
from tracker.models import Project, Session, NoteType, Change
from tracker.services import (
create_project,
get_project,
update_project,
list_projects,
ensure_project_structure,
get_active_session,
set_active_session,
clear_active_session,
validate_no_other_active_session,
add_note,
consolidate_notes,
suggest_next_steps,
generate_summary,
)
from tracker.storage import FileStorage, MarkdownReader, MarkdownWriter
# Initialize storage and markdown utilities
storage = FileStorage(projects_root=Path("projects"))
markdown_reader = MarkdownReader()
markdown_writer = MarkdownWriter()
# =============================================================================
# init-project command
# =============================================================================
def init_project(
name: str = typer.Argument(..., help="Project name"),
type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"),
tags: str = typer.Option("", help="Comma-separated tags"),
repo_path: Optional[str] = typer.Option(None, help="Path to git repository"),
description: str = typer.Option("", help="Project description"),
) -> None:
"""Create a new project with standard directory structure and files."""
from tracker.utils.slug import generate_slug
slug = generate_slug(name)
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
# Create project using service
project = create_project(
name=name,
slug=slug,
description=description,
type=type,
tags=tag_list,
repo_path=Path(repo_path) if repo_path else None,
)
# Ensure directory structure
ensure_project_structure(slug)
# Generate files from templates
readme_content = _generate_readme(project)
log_content = "# Log\n\n_Project activity log_\n\n"
changelog_content = "# Changelog\n\n_Project changes_\n\n"
tasks_content = _generate_tasks_template()
meta_content = _generate_meta(project)
# Write files
storage.write_file(slug, "README.md", readme_content)
storage.write_file(slug, "LOG.md", log_content)
storage.write_file(slug, "CHANGELOG.md", changelog_content)
storage.write_file(slug, "TASKS.md", tasks_content)
storage.write_file(slug, "meta/project.yaml", meta_content)
typer.echo(f"Created project '{name}' with slug '{slug}'")
typer.echo(f"Location: {storage._project_path(slug)}")
# =============================================================================
# list command
# =============================================================================
def list_projects_cmd() -> None:
"""Show all projects with their status, last session, and next steps."""
projects = list_projects()
if not projects:
typer.echo("No projects found. Create one with: tracker init-project <name>")
return
typer.echo(f"\n{'Name':<25} {'Slug':<20} {'Status':<10} {'Last Session':<20} {'Next Step'}")
typer.echo("-" * 100)
for project in projects:
last_session = "Never"
next_step = ""
# Try to get last session info from LOG.md
log_content = storage.read_log(project.slug)
if log_content:
parsed = markdown_reader.parse_log_entry(log_content)
if parsed.get("date_range"):
last_session = parsed["date_range"].split("")[0][:19]
if parsed.get("next_steps"):
next_step = parsed["next_steps"][0][:40]
typer.echo(
f"{project.name:<25} {project.slug:<20} {project.status:<10} "
f"{last_session:<20} {next_step}"
)
typer.echo(f"\nTotal: {len(projects)} project(s)")
# =============================================================================
# show command
# =============================================================================
def show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Show detailed project information including status, context, last summary, blockers, and next steps."""
# Load project
project_dict = storage.read_project_meta(slug)
if not project_dict:
typer.echo(f"Error: Project '{slug}' not found.", err=True)
raise typer.Exit(code=1)
project = Project(**project_dict)
typer.echo(f"\n{'='*60}")
typer.echo(f"Project: {project.name}")
typer.echo(f"{'='*60}")
# Basic info
typer.echo(f"\nSlug: {project.slug}")
typer.echo(f"Type: {project.type}")
typer.echo(f"Status: {project.status}")
typer.echo(f"Description: {project.description or 'N/A'}")
if project.tags:
typer.echo(f"Tags: {', '.join(project.tags)}")
# Read LOG.md for context
log_content = storage.read_log(slug)
if log_content:
parsed = markdown_reader.parse_log_entry(log_content)
# Last summary
if parsed.get("summary"):
typer.echo(f"\n--- Last Summary ---")
typer.echo(parsed["summary"][:300] + ("..." if len(parsed.get("summary", "")) > 300 else ""))
# Blockers
if parsed.get("blockers"):
typer.echo(f"\n--- Blockers ---")
for blocker in parsed["blockers"]:
typer.echo(f" - {blocker}")
# Next steps
if parsed.get("next_steps"):
typer.echo(f"\n--- Next Steps ---")
for step in parsed["next_steps"]:
typer.echo(f" - {step}")
# Last activity
if parsed.get("date_range"):
typer.echo(f"\n--- Last Activity ---")
typer.echo(f" {parsed['date_range']}")
typer.echo(f"\nLocation: {storage._project_path(slug)}")
# =============================================================================
# start command
# =============================================================================
def start_session(
slug: str = typer.Argument(..., help="Project slug"),
objective: Optional[str] = typer.Option(None, help="Session objective"),
) -> None:
"""Start a new work session for the project.
Validates that no other session is active, creates an active session,
and shows recent context from the project.
"""
# Check project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Validate no other active session
if not validate_no_other_active_session(slug):
active = get_active_session()
typer.echo(
f"Error: There is already an active session for project '{active.project_slug}'.",
err=True,
)
typer.echo(f"Stop that session first with: tracker stop {active.project_slug}")
raise typer.Exit(code=1)
# Create session
session = Session(
id=str(uuid.uuid4()),
project_slug=slug,
started_at=datetime.now(),
objective=objective or "",
)
# Save active session
set_active_session(session)
typer.echo(f"Started session for project '{slug}'")
if objective:
typer.echo(f"Objective: {objective}")
# Show recent context from LOG.md
typer.echo("\n--- Recent Context ---")
log_content = storage.read_log(slug)
if log_content:
parsed = markdown_reader.parse_log_entry(log_content)
if parsed.get("work_done"):
typer.echo("Recent work:")
for item in parsed["work_done"][:3]:
typer.echo(f" - {item[:60]}")
if parsed.get("next_steps"):
typer.echo("Next steps:")
for item in parsed["next_steps"][:3]:
typer.echo(f" - {item}")
else:
typer.echo("No previous sessions found.")
# =============================================================================
# note command
# =============================================================================
def add_note_cmd(
text: str = typer.Argument(..., help="Note text"),
type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"),
) -> None:
"""Add a note to the currently active session.
Note types:
- work: Work performed
- change: A change made
- blocker: Something blocking progress
- decision: A decision made
- idea: An idea
- reference: A reference or link
"""
# Get active session
session_data = storage.read_active_session()
if not session_data:
typer.echo("Error: No active session. Start one with: tracker start <slug>", err=True)
raise typer.Exit(code=1)
# Reconstruct session
session = Session(**session_data)
if isinstance(session.started_at, str):
session.started_at = datetime.fromisoformat(session.started_at)
# Add note
try:
note = add_note(session, type, text)
# Update active session file directly
storage.write_active_session(session.model_dump(mode="json"))
typer.echo(f"Added [{note['type']}] note: {text[:50]}{'...' if len(text) > 50 else ''}")
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1)
# =============================================================================
# stop command
# =============================================================================
def stop_session(
slug: str = typer.Argument(..., help="Project slug"),
add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"),
) -> None:
"""Stop the current session and generate summary.
Calculates duration, consolidates notes, generates summary,
suggests next steps, creates session file, updates LOG.md,
and optionally updates CHANGELOG.md.
"""
# Get active session
session_data = storage.read_active_session()
if not session_data:
typer.echo("Error: No active session to stop.", err=True)
raise typer.Exit(code=1)
session = Session(**session_data)
if isinstance(session.started_at, str):
session.started_at = datetime.fromisoformat(session.started_at)
# Verify it matches the slug
if session.project_slug != slug:
typer.echo(
f"Error: Active session is for project '{session.project_slug}', not '{slug}'.",
err=True,
)
raise typer.Exit(code=1)
# End session
session.ended_at = datetime.now()
session.duration_minutes = int((session.ended_at - session.started_at).total_seconds() / 60)
# Consolidate notes
consolidated = consolidate_notes(session.raw_notes)
session.work_done = consolidated["work_done"]
session.changes = consolidated["changes"]
session.decisions = consolidated["decisions"]
session.blockers = consolidated["blockers"]
session.references = consolidated["references"]
# Generate summary
session.summary = generate_summary(session)
# Get project for heuristics
project_dict = storage.read_project_meta(slug)
project = Project(**project_dict) if project_dict else None
# Suggest next steps
if project:
session.next_steps = suggest_next_steps(session, project)
else:
session.next_steps = suggest_next_steps(session, session)
# Save session file
storage.write_session_file(session)
# Format and append to LOG.md
log_entry = markdown_writer.format_log_entry(session, session.summary)
storage.append_to_log(slug, log_entry)
# Update README AUTOGEN sections
_update_readme_autogen(slug, session)
# Optionally add to changelog
if add_to_changelog and session.changes:
change = Change(
date=datetime.now().date(),
type="code",
title=f"Session: {session.objective or 'Work session'}",
impact=f"{session.duration_minutes} min - {len(session.work_done)} tasks completed",
)
changelog_entry = f"\n- **{change.date}** [{change.type}] {change.title}: {change.impact}"
storage.append_to_changelog(slug, changelog_entry)
# Clear active session
storage.delete_active_session()
# Print summary
typer.echo(f"\nSession completed for '{slug}'")
typer.echo(f"Duration: {session.duration_minutes} minutes")
typer.echo(f"\nSummary:\n{session.summary}")
if session.next_steps:
typer.echo("\nSuggested next steps:")
for step in session.next_steps:
typer.echo(f" - {step}")
# =============================================================================
# change command
# =============================================================================
def add_change(
slug: str = typer.Argument(..., help="Project slug"),
type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"),
title: str = typer.Option(..., help="Change title"),
impact: str = typer.Option("", help="Impact description"),
) -> None:
"""Add a manual entry to the project's CHANGELOG.md."""
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
change = Change(
date=datetime.now().date(),
type=type,
title=title,
impact=impact,
)
entry = f"- **{change.date}** [{change.type}] {change.title}: {change.impact}"
storage.append_to_changelog(slug, entry)
typer.echo(f"Added change to CHANGELOG.md:")
typer.echo(f" {entry}")
# =============================================================================
# next command
# =============================================================================
def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Suggest next steps based on project history and heuristics.
Uses simple rules:
- If there are open blockers, suggest unblocking
- If there are unvalidated changes, suggest validation
- If work is incomplete, suggest closing open threads
- If no progress, suggest redefining objective
"""
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Get active session if exists
session_data = storage.read_active_session()
if session_data:
session = Session(**session_data)
if isinstance(session.started_at, str):
session.started_at = datetime.fromisoformat(session.started_at)
else:
# Create a minimal session from project context
session = Session(
id="",
project_slug=slug,
started_at=datetime.now(),
)
# Load recent work from LOG
log_content = storage.read_log(slug)
if log_content:
parsed = markdown_reader.parse_log_entry(log_content)
session.work_done = parsed.get("work_done", [])
session.changes = parsed.get("changes", [])
session.blockers = parsed.get("blockers", [])
session.decisions = parsed.get("decisions", [])
# Get project
project_dict = storage.read_project_meta(slug)
project = Project(**project_dict) if project_dict else None
# Get suggestions
suggestions = suggest_next_steps(session, project or Project(
id="",
name="",
slug=slug,
description="",
type="misc",
status="active",
tags=[],
root_path="",
created_at=datetime.now(),
updated_at=datetime.now(),
))
if suggestions:
typer.echo(f"\nSuggested next steps for '{slug}':")
for i, suggestion in enumerate(suggestions, 1):
typer.echo(f" {i}. {suggestion}")
else:
typer.echo(f"\nNo specific suggestions for '{slug}'.")
typer.echo("Consider defining new objectives or reviewing the project status.")
# =============================================================================
# review command
# =============================================================================
def review() -> None:
"""Show an overview of all projects.
Displays:
- Active projects
- Recent sessions
- Open blockers
- Projects without recent activity
"""
projects = list_projects()
if not projects:
typer.echo("No projects to review.")
return
typer.echo("\n" + "=" * 60)
typer.echo("PROJECT REVIEW")
typer.echo("=" * 60)
# Categorize projects
active_projects = [p for p in projects if p.status == "active"]
blocked_projects = [p for p in projects if p.status == "blocked"]
other_projects = [p for p in projects if p.status not in ("active", "blocked")]
# Active projects
if active_projects:
typer.echo(f"\n--- Active Projects ({len(active_projects)}) ---")
for p in active_projects:
typer.echo(f" * {p.name} ({p.slug})")
else:
typer.echo("\n--- No Active Projects ---")
# Blocked projects
if blocked_projects:
typer.echo(f"\n--- Blocked Projects ({len(blocked_projects)}) ---")
for p in blocked_projects:
typer.echo(f" ! {p.name} ({p.slug})")
# Check for blockers in LOG
log_content = storage.read_log(p.slug)
if log_content:
parsed = markdown_reader.parse_log_entry(log_content)
for blocker in parsed.get("blockers", []):
typer.echo(f" - {blocker}")
# Recent sessions
typer.echo(f"\n--- Recent Sessions ---")
has_recent = False
for p in projects:
sessions = storage.get_recent_sessions(p.slug, limit=1)
if sessions:
recent = sessions[0]
typer.echo(f" {p.name}: {recent.started_at.strftime('%Y-%m-%d %H:%M')} ({recent.duration_minutes} min)")
has_recent = True
if not has_recent:
typer.echo(" No recent sessions")
# Check for stale projects (no activity in 7 days)
typer.echo(f"\n--- Projects Without Recent Activity ---")
stale_threshold = datetime.now().timestamp() - (7 * 24 * 60 * 60)
stale_found = False
for p in projects:
if p.last_session_at:
if p.last_session_at.timestamp() < stale_threshold:
days = int((datetime.now() - p.last_session_at).days)
typer.echo(f" {p.name}: {days} days since last session")
stale_found = True
else:
# Check LOG for any sessions
log_content = storage.read_log(p.slug)
if not log_content:
typer.echo(f" {p.name}: Never had a session")
stale_found = True
if not stale_found:
typer.echo(" All projects have recent activity")
typer.echo("\n" + "=" * 60)
# =============================================================================
# Helper functions
# =============================================================================
def _generate_readme(project: Project) -> str:
"""Generate README.md content for a new project."""
return f"""# {project.name}
{project.description or '_No description_'}
## Objective
_TODO: Define objective_
## Status
**Current Status:** {project.status}
<!-- AUTOGEN:STATUS_START -->
Status: {project.status}
<!-- AUTOGEN:STATUS_END -->
## Context
_Current context and background_
## Stack / Tools
- _Tool 1_
- _Tool 2_
## Architecture
_Brief architecture description_
## Technical Decisions
_No decisions recorded yet_
## Risks / Blockers
_No blockers_
<!-- AUTOGEN:NEXT_STEPS_START -->
<!-- AUTOGEN:NEXT_STEPS_END -->
## Recent Sessions
<!-- AUTOGEN:SESSIONS_START -->
<!-- AUTOGEN:SESSIONS_END -->
_Last updated: {datetime.now().strftime('%Y-%m-%d')}_
"""
def _generate_tasks_template() -> str:
"""Generate TASKS.md content for a new project."""
return """# Tasks
## Inbox
-
## Next
-
## In Progress
-
## Blocked
-
## Waiting
-
## Done
-
"""
def _generate_meta(project: Project) -> str:
"""Generate meta/project.yaml content."""
import yaml
data = {
"id": project.id,
"name": project.name,
"slug": project.slug,
"description": project.description,
"type": project.type,
"status": project.status,
"tags": project.tags,
"root_path": str(project.root_path),
"repo_path": str(project.repo_path) if project.repo_path else None,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat(),
"last_session_at": None,
}
return yaml.dump(data, default_flow_style=False, sort_keys=False)
def _update_readme_autogen(slug: str, session: Session) -> None:
"""Update README.md AUTOGEN sections with session info."""
# Update sessions section
session_line = f"- {session.started_at.strftime('%Y-%m-%d %H:%M')} ({session.duration_minutes} min): {session.summary[:50]}..."
sessions_content = storage.extract_autogen_section(slug, "SESSIONS") or ""
sessions_content = sessions_content + f"\n{session_line}" if sessions_content else session_line
storage.update_readme_autogen(slug, "SESSIONS", sessions_content.strip())
# Update next steps section
if session.next_steps:
next_steps_content = "\n".join([f"- {step}" for step in session.next_steps])
existing_next = storage.extract_autogen_section(slug, "NEXT_STEPS") or ""
if existing_next:
# Keep existing and add new
next_steps_content = existing_next + "\n" + next_steps_content
storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip())
# =============================================================================
# task add command
# =============================================================================
def task_add(
slug: str = typer.Argument(..., help="Project slug"),
title: str = typer.Argument(..., help="Task title"),
section: str = typer.Option("Inbox", help="Section name (Inbox, Próximo, En curso, Bloqueado, En espera, Hecho)"),
) -> None:
"""Add a task to the project's TASKS.md.
Valid sections: Inbox, Próximo, En curso, Bloqueado, En espera, Hecho.
If the section doesn't exist, it will be created.
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections (Spanish names as per spec)
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if section not in valid_sections:
typer.echo(
f"Error: Invalid section '{section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Add task to the specified section
new_task = f"- [ ] {title}"
if section in sections:
sections[section].append(new_task)
else:
# Create new section with the task
sections[section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
typer.echo(f"Added task to '{section}' section: {title}")
# =============================================================================
# task move command
# =============================================================================
def task_move(
slug: str = typer.Argument(..., help="Project slug"),
task_title: str = typer.Argument(..., help="Task title to search for"),
to_section: str = typer.Argument(..., help="Destination section"),
) -> None:
"""Move a task to a different section in TASKS.md.
Searches for the task by title in any section and moves it to the destination.
Tasks moved to 'Hecho' will be marked as completed ([x]).
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if to_section not in valid_sections:
typer.echo(
f"Error: Invalid section '{to_section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Find the task
found_task = None
found_in_section = None
task_pattern = f"- [ ] {task_title}"
task_pattern_done = f"- [x] {task_title}"
for section_name, tasks in sections.items():
for task in tasks:
if task == task_pattern or task == task_pattern_done:
found_task = task
found_in_section = section_name
break
if found_task:
break
if not found_task:
typer.echo(f"Error: Task '{task_title}' not found in any section.", err=True)
raise typer.Exit(code=1)
if found_in_section == to_section:
typer.echo(f"Task '{task_title}' is already in '{to_section}' section.")
return
# Remove from original section
sections[found_in_section].remove(found_task)
if not sections[found_in_section]:
del sections[found_in_section]
# Determine checkbox state based on destination section
if to_section == "Hecho":
new_task = f"- [x] {task_title}"
else:
new_task = f"- [ ] {task_title}"
# Add to destination section
if to_section in sections:
sections[to_section].append(new_task)
else:
sections[to_section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
checkbox = "[x]" if to_section == "Hecho" else "[ ]"
typer.echo(f"Moved task '{task_title}' from '{found_in_section}' to '{to_section}' ({checkbox})")
# =============================================================================
# Helper functions for TASKS.md parsing
# =============================================================================
def _parse_tasks_sections(content: str) -> dict:
"""Parse TASKS.md content into sections and tasks.
Returns a dict mapping section names to lists of task strings.
Normalizes English section names to Spanish and merges duplicate sections.
"""
import re
# Mapping from English to Spanish section names
section_mapping = {
"Next": "Próximo",
"In Progress": "En curso",
"Blocked": "Bloqueado",
"Waiting": "En espera",
"Done": "Hecho",
"Inbox": "Inbox", # Same in both
}
sections = {}
current_section = None
current_tasks = []
# Match section headers (## Section Name)
section_pattern = re.compile(r"^##\s+(.+)$")
# Match task items (- [ ] task or - [x] task)
task_pattern = re.compile(r"^(- \[[ x]\]) (.+)$")
def save_current_section():
"""Save current section tasks, merging if normalized name already exists."""
if current_section is not None and current_tasks:
if current_section in sections:
sections[current_section].extend(current_tasks)
else:
sections[current_section] = current_tasks
for line in content.split("\n"):
section_match = section_pattern.match(line)
if section_match:
# Save previous section if exists
save_current_section()
raw_section = section_match.group(1)
# Normalize section name
current_section = section_mapping.get(raw_section, raw_section)
current_tasks = []
else:
task_match = task_pattern.match(line)
if task_match and current_section is not None:
checkbox = task_match.group(1)
title = task_match.group(2)
current_tasks.append(f"{checkbox} {title}")
# Save last section
save_current_section()
return sections
def _build_tasks_content(sections: dict) -> str:
"""Build TASKS.md content from sections dict.
Maintains the order of sections as specified.
"""
section_order = ["Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"]
lines = ["# Tasks", ""]
for section_name in section_order:
lines.append(f"## {section_name}")
tasks = sections.get(section_name, [])
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
# Add any sections not in the standard order
for section_name in sections:
if section_name not in section_order:
lines.append(f"## {section_name}")
tasks = sections[section_name]
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# Register all commands at module level for direct access
__all__ = [
"init_project",
"list_projects_cmd",
"show_project",
"start_session",
"add_note_cmd",
"stop_session",
"add_change",
"suggest_next",
"review",
"task_add",
"task_move",
]

53
tracker/cli/main.py Normal file
View File

@@ -0,0 +1,53 @@
"""Main CLI entry point."""
import typer
from tracker.cli.commands import (
init_project,
list_projects_cmd,
show_project,
start_session,
add_note_cmd,
stop_session,
add_change,
suggest_next,
review,
task_add,
task_move,
)
app = typer.Typer(
name="tracker",
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
)
# Sub-command group for task management
task_app = typer.Typer(help="Task management commands")
# Register task subcommands
task_app.command("add")(task_add)
task_app.command("move")(task_move)
# Register all commands
app.command("init-project")(init_project)
app.command("list")(list_projects_cmd)
app.command("show")(show_project)
app.command("start")(start_session)
app.command("note")(add_note_cmd)
app.command("stop")(stop_session)
app.command("change")(add_change)
app.command("next")(suggest_next)
app.command("review")(review)
app.add_typer(task_app, name="task")
@app.callback()
def callback():
"""Personal Project Tracker - Track your projects locally with Markdown."""
pass
if __name__ == "__main__":
app()

View File

@@ -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"]

13
tracker/models/change.py Normal file
View File

@@ -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)

22
tracker/models/note.py Normal file
View File

@@ -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)

21
tracker/models/project.py Normal file
View File

@@ -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

23
tracker/models/session.py Normal file
View File

@@ -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": "..."}]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,128 @@
"""Project service for project management."""
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
import yaml
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=str(_PROJECTS_ROOT / slug),
repo_path=str(repo_path) if repo_path else None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
return project
def get_project(slug: str) -> Optional[Project]:
"""
Get a project by slug.
Reads from meta/project.yaml in the project directory.
"""
meta_path = _get_project_meta_path(slug)
if not meta_path.exists():
return None
try:
with open(meta_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data:
return Project(**data)
except (yaml.YAMLError, TypeError):
pass
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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"]

View File

@@ -0,0 +1,266 @@
"""Storage layer for file-based persistence."""
import json
from datetime import datetime
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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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 <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
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/<slug>/sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente."""
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
md_filename = started.strftime("%Y-%m-%d_%H%M.md")
json_filename = f"{session.id}.json"
# Write markdown file
writer = MarkdownWriter()
content = writer.format_session_file(session)
md_path = sessions_path / md_filename
with open(md_path, "w", encoding="utf-8") as f:
f.write(content)
# Write JSON file for tracking
json_path = sessions_path / json_filename
session_data = session.model_dump(mode="json")
# Serialize datetime objects to ISO format
if isinstance(session_data.get("started_at"), datetime):
session_data["started_at"] = session_data["started_at"].isoformat()
if isinstance(session_data.get("ended_at"), datetime):
session_data["ended_at"] = session_data["ended_at"].isoformat()
with open(json_path, "w", encoding="utf-8") as f:
json.dump(session_data, f, indent=2, ensure_ascii=False, default=str)
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()
def write_file(self, slug: str, relative_path: str, content: str) -> None:
"""Escribe contenido a un archivo en el proyecto.
Args:
slug: Project slug.
relative_path: Relative path within the project.
content: Content to write.
"""
file_path = self._project_path(slug) / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
def read_file(self, slug: str, relative_path: str) -> str:
"""Lee contenido de un archivo en el proyecto.
Args:
slug: Project slug.
relative_path: Relative path within the project.
Returns:
File content or empty string if not found.
"""
file_path = self._project_path(slug) / relative_path
if not file_path.exists():
return ""
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
def extract_autogen_section(self, slug: str, section: str) -> str:
"""Extrae contenido de una seccion AUTOGEN del README.md.
Args:
slug: Project slug.
section: Section name (e.g., "SESSIONS", "NEXT_STEPS").
Returns:
Content between AUTOGEN markers, or empty string if not found.
"""
from tracker.storage.markdown_reader import MarkdownReader
reader = MarkdownReader()
content = self.read_readme(slug)
return reader.extract_autogen_section(content, section)
def get_recent_sessions(self, slug: str, limit: int = 5) -> list:
"""Obtiene las sesiones mas recientes de un proyecto.
Args:
slug: Project slug.
limit: Maximum number of sessions to return.
Returns:
List of Session objects sorted by date (most recent first).
"""
from tracker.models.session import Session
sessions_path = self._sessions_path(slug)
if not sessions_path.exists():
return []
sessions = []
for json_file in sessions_path.glob("*.json"):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
session = Session(**data)
sessions.append(session)
except (json.JSONDecodeError, TypeError):
continue
# Sort by started_at descending
sessions.sort(key=lambda s: s.started_at, reverse=True)
return sessions[:limit]
def list_projects(self) -> list[str]:
"""Lista todos los slugs de proyectos.
Returns:
List of project slugs.
"""
if not self.projects_root.exists():
return []
return [d.name for d in self.projects_root.iterdir() if d.is_dir() and not d.name.startswith(".")]
def project_exists(self, slug: str) -> bool:
"""Verifica si un proyecto existe.
Args:
slug: Project slug.
Returns:
True if project exists.
"""
return self._project_path(slug).exists()

View File

@@ -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:0011: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 <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
Returns el contenido entre esos marcadores, o string vacio si no existe.
"""
pattern = rf"<!--\s*AUTOGEN:{section}_START\s*-->(.*?)<!--\s*AUTOGEN:{section}_END\s*-->"
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

View File

@@ -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:0011: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:0011: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 <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
Si existe, reemplaza el contenido entre los marcadores.
Si no existe, inserta la seccion al final.
Returns el contenido modificado.
"""
start_marker = f"<!-- AUTOGEN:{section}_START -->"
end_marker = f"<!-- AUTOGEN:{section}_END -->"
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

View File

@@ -0,0 +1,153 @@
"""Templates package for generating project files."""
from datetime import datetime
from typing import Optional
from tracker.models import Project
def get_readme_template(project: Optional[Project] = None) -> str:
"""Get the README.md template for a project.
Args:
project: Optional project instance for personalization.
Returns:
README.md template string.
"""
name = project.name if project else "Project Name"
description = project.description if project else "_No description_"
status = project.status if project else "inbox"
return f"""# {name}
{description}
## Objective
_TODO: Define objective_
## Status
**Current Status:** {status}
<!-- AUTOGEN:STATUS_START -->
Status: {status}
<!-- AUTOGEN:STATUS_END -->
## Context
_Current context and background_
## Stack / Tools
- _Tool 1_
- _Tool 2_
## Architecture
_Brief architecture description_
## Technical Decisions
_No decisions recorded yet_
## Risks / Blockers
_No blockers_
<!-- AUTOGEN:NEXT_STEPS_START -->
<!-- AUTOGEN:NEXT_STEPS_END -->
## Recent Sessions
<!-- AUTOGEN:SESSIONS_START -->
<!-- AUTOGEN:SESSIONS_END -->
_Last updated: {datetime.now().strftime('%Y-%m-%d')}_
"""
def get_log_template() -> str:
"""Get the LOG.md template.
Returns:
LOG.md template string.
"""
return """# Log
_Project activity log_
"""
def get_changelog_template() -> str:
"""Get the CHANGELOG.md template.
Returns:
CHANGELOG.md template string.
"""
return """# Changelog
_Project changes_
"""
def get_tasks_template() -> str:
"""Get the TASKS.md template.
Returns:
TASKS.md template string.
"""
return """# Tasks
## Inbox
-
## Next
-
## In Progress
-
## Blocked
-
## Waiting
-
## Done
-
"""
def get_meta_template(project: Project) -> dict:
"""Get the meta/project.yaml template data.
Args:
project: Project instance.
Returns:
Dictionary suitable for YAML serialization.
"""
return {
"id": project.id,
"name": project.name,
"slug": project.slug,
"description": project.description,
"type": project.type,
"status": project.status,
"tags": project.tags,
"root_path": str(project.root_path),
"repo_path": str(project.repo_path) if project.repo_path else None,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat(),
"last_session_at": None,
}

View File

@@ -0,0 +1 @@
"""Utility functions."""

87
tracker/utils/path.py Normal file
View File

@@ -0,0 +1,87 @@
"""Path utility functions."""
from pathlib import Path
from typing import Optional
def ensure_dir(path: Path) -> Path:
"""Ensure a directory exists, creating it if necessary.
Args:
path: Path to the directory.
Returns:
The path to the directory.
"""
path.mkdir(parents=True, exist_ok=True)
return path
def project_root(slug: str, projects_root: Optional[Path] = None) -> Path:
"""Get the root path for a project.
Args:
slug: Project slug.
projects_root: Root directory for all projects. Defaults to ./projects.
Returns:
Path to the project root.
"""
if projects_root is None:
projects_root = Path("projects")
return projects_root / slug
def relative_to_project(slug: str, relative_path: str, projects_root: Optional[Path] = None) -> Path:
"""Get a path relative to a project.
Args:
slug: Project slug.
relative_path: Relative path within the project.
projects_root: Root directory for all projects.
Returns:
Absolute path to the file within the project.
"""
root = project_root(slug, projects_root)
return root / relative_path
def is_within_project(slug: str, file_path: Path, projects_root: Optional[Path] = None) -> bool:
"""Check if a file path is within a project.
Args:
slug: Project slug.
file_path: Path to check.
projects_root: Root directory for all projects.
Returns:
True if file_path is within the project directory.
"""
project_path = project_root(slug, projects_root)
try:
file_path.resolve().relative_to(project_path.resolve())
return True
except ValueError:
return False
def sanitize_filename(filename: str) -> str:
"""Sanitize a filename by removing invalid characters.
Args:
filename: Original filename.
Returns:
Sanitized filename safe for file system use.
"""
# Remove or replace invalid characters
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, "_")
# Limit length
if len(filename) > 255:
filename = filename[:255]
return filename

13
tracker/utils/slug.py Normal file
View File

@@ -0,0 +1,13 @@
"""Slug generation utility."""
def generate_slug(name: str) -> str:
"""Generate a URL-safe slug from a name.
Args:
name: The name to convert to a slug.
Returns:
A lowercase slug with spaces replaced by hyphens.
"""
return name.lower().replace(" ", "-")

91
tracker/utils/time.py Normal file
View File

@@ -0,0 +1,91 @@
"""Time utility functions."""
from datetime import datetime, timedelta
def format_duration(minutes: int) -> str:
"""Format duration in minutes to human-readable string.
Args:
minutes: Duration in minutes.
Returns:
Human-readable duration string (e.g., "1h 30m", "45m").
"""
if minutes < 60:
return f"{minutes}m"
hours = minutes // 60
remaining_minutes = minutes % 60
if remaining_minutes == 0:
return f"{hours}h"
return f"{hours}h {remaining_minutes}m"
def parse_duration(duration_str: str) -> int:
"""Parse duration string to minutes.
Args:
duration_str: Duration string like "1h 30m", "45m", "2h".
Returns:
Duration in minutes.
"""
total_minutes = 0
duration_str = duration_str.lower().strip()
# Parse hours
if "h" in duration_str:
parts = duration_str.split()
for part in parts:
if "h" in part:
total_minutes += int(part.replace("h", "")) * 60
elif "m" in part:
total_minutes += int(part.replace("m", ""))
# If no hours, try just minutes
if total_minutes == 0 and "m" in duration_str:
total_minutes = int(duration_str.replace("m", ""))
elif total_minutes == 0:
try:
total_minutes = int(duration_str)
except ValueError:
return 0
return total_minutes
def is_recent(dt: datetime, hours: int = 24) -> bool:
"""Check if a datetime is within the specified hours.
Args:
dt: Datetime to check.
hours: Number of hours to consider as "recent".
Returns:
True if dt is within the specified hours.
"""
return datetime.now() - dt < timedelta(hours=hours)
def format_datetime(dt: datetime) -> str:
"""Format datetime to standard string.
Args:
dt: Datetime to format.
Returns:
Formatted string in YYYY-MM-DD HH:MM format.
"""
return dt.strftime("%Y-%m-%d %H:%M")
def format_date(dt: datetime) -> str:
"""Format datetime to date string.
Args:
dt: Datetime to format.
Returns:
Formatted string in YYYY-MM-DD format.
"""
return dt.strftime("%Y-%m-%d")