Compare commits
4 Commits
main
...
40a33d773b
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a33d773b | |||
| 88a474a78d | |||
| b0c65a00a2 | |||
| 4547c492da |
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
0
projects/.gitkeep
Normal file
0
projects/.gitkeep
Normal file
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[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"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests package."""
|
||||||
1
tracker/__init__.py
Normal file
1
tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Personal Tracker CLI - A Markdown-based project tracking system."""
|
||||||
1
tracker/cli/__init__.py
Normal file
1
tracker/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""CLI commands for the tracker."""
|
||||||
709
tracker/cli/commands.py
Normal file
709
tracker/cli/commands.py
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
"""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
|
||||||
|
# =============================================================================
|
||||||
|
init_project = typer.Typer(help="Create a new project with standard structure.")
|
||||||
|
|
||||||
|
|
||||||
|
@init_project.command("init-project")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
list_projects = typer.Typer(help="List all projects.")
|
||||||
|
|
||||||
|
|
||||||
|
@list_projects.command("list")
|
||||||
|
def cmd_list_projects() -> 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
|
||||||
|
# =============================================================================
|
||||||
|
show_project = typer.Typer(help="Show project details.")
|
||||||
|
|
||||||
|
|
||||||
|
@show_project.command("show")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
start_session = typer.Typer(help="Start a work session.")
|
||||||
|
|
||||||
|
|
||||||
|
@start_session.command("start")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
add_note_cmd = typer.Typer(help="Add a note to the active session.")
|
||||||
|
|
||||||
|
|
||||||
|
@add_note_cmd.command("note")
|
||||||
|
def cmd_add_note(
|
||||||
|
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
|
||||||
|
# =============================================================================
|
||||||
|
stop_session = typer.Typer(help="Stop the current session.")
|
||||||
|
|
||||||
|
|
||||||
|
@stop_session.command("stop")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
add_change = typer.Typer(help="Add a change entry to CHANGELOG.md.")
|
||||||
|
|
||||||
|
|
||||||
|
@add_change.command("change")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
suggest_next = typer.Typer(help="Suggest next steps for a project.")
|
||||||
|
|
||||||
|
|
||||||
|
@suggest_next.command("next")
|
||||||
|
def cmd_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
|
||||||
|
# =============================================================================
|
||||||
|
review = typer.Typer(help="Review all projects.")
|
||||||
|
|
||||||
|
|
||||||
|
@review.command("review")
|
||||||
|
def cmd_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())
|
||||||
|
|
||||||
|
|
||||||
|
# Register all commands at module level for direct access
|
||||||
|
__all__ = [
|
||||||
|
"init_project",
|
||||||
|
"list_projects",
|
||||||
|
"show_project",
|
||||||
|
"start_session",
|
||||||
|
"add_note_cmd",
|
||||||
|
"stop_session",
|
||||||
|
"add_change",
|
||||||
|
"suggest_next",
|
||||||
|
"review",
|
||||||
|
]
|
||||||
42
tracker/cli/main.py
Normal file
42
tracker/cli/main.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Main CLI entry point."""
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from tracker.cli.commands import (
|
||||||
|
init_project,
|
||||||
|
list_projects,
|
||||||
|
show_project,
|
||||||
|
start_session,
|
||||||
|
add_note_cmd,
|
||||||
|
stop_session,
|
||||||
|
add_change,
|
||||||
|
suggest_next,
|
||||||
|
review,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="tracker",
|
||||||
|
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Register all subcommands
|
||||||
|
app.add_typer_command(init_project, name="init-project")
|
||||||
|
app.add_typer_command(list_projects, name="list")
|
||||||
|
app.add_typer_command(show_project, name="show")
|
||||||
|
app.add_typer_command(start_session, name="start")
|
||||||
|
app.add_typer_command(add_note_cmd, name="note")
|
||||||
|
app.add_typer_command(stop_session, name="stop")
|
||||||
|
app.add_typer_command(add_change, name="change")
|
||||||
|
app.add_typer_command(suggest_next, name="next")
|
||||||
|
app.add_typer_command(review, name="review")
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def callback():
|
||||||
|
"""Personal Project Tracker - Track your projects locally with Markdown."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
7
tracker/models/__init__.py
Normal file
7
tracker/models/__init__.py
Normal 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
13
tracker/models/change.py
Normal 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
22
tracker/models/note.py
Normal 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
21
tracker/models/project.py
Normal 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
23
tracker/models/session.py
Normal 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": "..."}]
|
||||||
45
tracker/services/__init__.py
Normal file
45
tracker/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
47
tracker/services/heuristics_service.py
Normal file
47
tracker/services/heuristics_service.py
Normal 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
|
||||||
65
tracker/services/note_service.py
Normal file
65
tracker/services/note_service.py
Normal 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
|
||||||
118
tracker/services/project_service.py
Normal file
118
tracker/services/project_service.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Project service for project management."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..models import Project
|
||||||
|
|
||||||
|
|
||||||
|
_PROJECTS_ROOT = Path("projects")
|
||||||
|
|
||||||
|
|
||||||
|
def get_projects_root() -> Path:
|
||||||
|
"""Return the root directory for all projects."""
|
||||||
|
return _PROJECTS_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_meta_path(slug: str) -> Path:
|
||||||
|
"""Return the path to the project's meta/project.yaml file."""
|
||||||
|
return _PROJECTS_ROOT / slug / "meta" / "project.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_readme_path(slug: str) -> Path:
|
||||||
|
"""Return the path to the project's README.md file."""
|
||||||
|
return _PROJECTS_ROOT / slug / "README.md"
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
name: str,
|
||||||
|
slug: str,
|
||||||
|
description: str = "",
|
||||||
|
type: str = "misc",
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
repo_path: Optional[Path] = None,
|
||||||
|
) -> Project:
|
||||||
|
"""
|
||||||
|
Create a new project and return the Project instance.
|
||||||
|
Note: This does not write any files - that is handled by storage.
|
||||||
|
"""
|
||||||
|
if tags is None:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=name,
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
type=type,
|
||||||
|
status="inbox",
|
||||||
|
tags=tags,
|
||||||
|
root_path=_PROJECTS_ROOT / slug,
|
||||||
|
repo_path=repo_path,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def get_project(slug: str) -> Optional[Project]:
|
||||||
|
"""
|
||||||
|
Get a project by slug.
|
||||||
|
Note: This reads from file system - placeholder for storage integration.
|
||||||
|
"""
|
||||||
|
meta_path = _get_project_meta_path(slug)
|
||||||
|
if not meta_path.exists():
|
||||||
|
return None
|
||||||
|
# TODO: Load from storage (YAML)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_project(slug: str, **kwargs) -> Optional[Project]:
|
||||||
|
"""
|
||||||
|
Update a project's attributes.
|
||||||
|
Note: This does not persist - that is handled by storage.
|
||||||
|
"""
|
||||||
|
project = get_project(slug)
|
||||||
|
if project is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(project, key):
|
||||||
|
setattr(project, key, value)
|
||||||
|
|
||||||
|
project.updated_at = datetime.now()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects() -> list[Project]:
|
||||||
|
"""
|
||||||
|
List all projects.
|
||||||
|
Note: This reads from file system - placeholder for storage integration.
|
||||||
|
"""
|
||||||
|
projects_root = get_projects_root()
|
||||||
|
if not projects_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for item in projects_root.iterdir():
|
||||||
|
if item.is_dir() and not item.name.startswith("."):
|
||||||
|
project = get_project(item.name)
|
||||||
|
if project is not None:
|
||||||
|
projects.append(project)
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_structure(slug: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure the project directory structure exists.
|
||||||
|
Creates: sessions/, docs/, assets/, meta/
|
||||||
|
Note: This creates directories only - actual file writing is storage's job.
|
||||||
|
"""
|
||||||
|
project_root = _PROJECTS_ROOT / slug
|
||||||
|
directories = ["sessions", "docs", "assets", "meta"]
|
||||||
|
|
||||||
|
for directory in directories:
|
||||||
|
(project_root / directory).mkdir(parents=True, exist_ok=True)
|
||||||
67
tracker/services/session_service.py
Normal file
67
tracker/services/session_service.py
Normal 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
|
||||||
42
tracker/services/summary_service.py
Normal file
42
tracker/services/summary_service.py
Normal 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)
|
||||||
5
tracker/storage/__init__.py
Normal file
5
tracker/storage/__init__.py
Normal 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"]
|
||||||
158
tracker/storage/file_storage.py
Normal file
158
tracker/storage/file_storage.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Storage layer for file-based persistence."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from tracker.models.session import Session
|
||||||
|
|
||||||
|
|
||||||
|
class FileStorage:
|
||||||
|
"""Maneja lectura/escritura de archivos del proyecto."""
|
||||||
|
|
||||||
|
def __init__(self, projects_root: Path):
|
||||||
|
self.projects_root = projects_root
|
||||||
|
|
||||||
|
def _project_path(self, slug: str) -> Path:
|
||||||
|
return self.projects_root / slug
|
||||||
|
|
||||||
|
def _meta_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "meta" / "project.yaml"
|
||||||
|
|
||||||
|
def _log_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "LOG.md"
|
||||||
|
|
||||||
|
def _changelog_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "CHANGELOG.md"
|
||||||
|
|
||||||
|
def _tasks_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "TASKS.md"
|
||||||
|
|
||||||
|
def _readme_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "README.md"
|
||||||
|
|
||||||
|
def _sessions_path(self, slug: str) -> Path:
|
||||||
|
return self._project_path(slug) / "sessions"
|
||||||
|
|
||||||
|
def read_project_meta(self, slug: str) -> dict:
|
||||||
|
"""Lee projects/<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"""
|
||||||
|
from tracker.storage.markdown_writer import MarkdownWriter
|
||||||
|
|
||||||
|
sessions_path = self._sessions_path(session.project_slug)
|
||||||
|
sessions_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
started = session.started_at
|
||||||
|
filename = started.strftime("%Y-%m-%d_%H%M.md")
|
||||||
|
session_path = sessions_path / filename
|
||||||
|
|
||||||
|
writer = MarkdownWriter()
|
||||||
|
content = writer.format_session_file(session)
|
||||||
|
|
||||||
|
with open(session_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def active_session_path(self) -> Path:
|
||||||
|
"""Returns Path to projects/.active_session.json"""
|
||||||
|
return self.projects_root / ".active_session.json"
|
||||||
|
|
||||||
|
def read_active_session(self) -> Optional[dict]:
|
||||||
|
"""Lee la sesion activa desde .active_session.json"""
|
||||||
|
path = self.active_session_path()
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def write_active_session(self, session_data: dict) -> None:
|
||||||
|
"""Escribe la sesion activa a .active_session.json"""
|
||||||
|
path = self.active_session_path()
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(session_data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
def delete_active_session(self) -> None:
|
||||||
|
"""Elimina .active_session.json"""
|
||||||
|
path = self.active_session_path()
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
138
tracker/storage/markdown_reader.py
Normal file
138
tracker/storage/markdown_reader.py
Normal 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:00–11:20
|
||||||
|
**Objetivo**
|
||||||
|
...
|
||||||
|
**Trabajo realizado**
|
||||||
|
- ...
|
||||||
|
**Cambios relevantes**
|
||||||
|
- ...
|
||||||
|
**Bloqueos**
|
||||||
|
- ...
|
||||||
|
**Decisiones**
|
||||||
|
- ...
|
||||||
|
**Próximos pasos**
|
||||||
|
- ...
|
||||||
|
**Resumen**
|
||||||
|
...
|
||||||
|
|
||||||
|
Returns dict con:
|
||||||
|
- date_range: str
|
||||||
|
- objective: str
|
||||||
|
- work_done: list[str]
|
||||||
|
- changes: list[str]
|
||||||
|
- blockers: list[str]
|
||||||
|
- decisions: list[str]
|
||||||
|
- next_steps: list[str]
|
||||||
|
- summary: str
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"date_range": "",
|
||||||
|
"objective": "",
|
||||||
|
"work_done": [],
|
||||||
|
"changes": [],
|
||||||
|
"blockers": [],
|
||||||
|
"decisions": [],
|
||||||
|
"next_steps": [],
|
||||||
|
"summary": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extraer fecha/rango
|
||||||
|
date_match = re.search(r"##\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}[–-]\d{2}:\d{2})", content)
|
||||||
|
if date_match:
|
||||||
|
result["date_range"] = date_match.group(1)
|
||||||
|
|
||||||
|
# Extraer secciones
|
||||||
|
sections = {
|
||||||
|
"objective": r"\*\*Objetivo\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"work_done": r"\*\*Trabajo realizado\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"changes": r"\*\*Cambios relevantes\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"blockers": r"\*\*Bloqueos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"decisions": r"\*\*Decisiones\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"next_steps": r"\*\*Próximos pasos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||||
|
"summary": r"\*\*Resumen\*\*\s*\n(.*?)(?=\n##|\Z)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, pattern in sections.items():
|
||||||
|
match = re.search(pattern, content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
text = match.group(1).strip()
|
||||||
|
if key in ("work_done", "changes", "blockers", "decisions", "next_steps"):
|
||||||
|
# Extraer listas con bullet points
|
||||||
|
items = re.findall(r"^\s*-\s+(.+)$", text, re.MULTILINE)
|
||||||
|
result[key] = items
|
||||||
|
else:
|
||||||
|
result[key] = text
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def extract_autogen_section(self, content: str, section: str) -> str:
|
||||||
|
"""Extrae contenido de una seccion AUTOGEN.
|
||||||
|
|
||||||
|
Busca <!-- 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
|
||||||
241
tracker/storage/markdown_writer.py
Normal file
241
tracker/storage/markdown_writer.py
Normal 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:00–11:20
|
||||||
|
**Objetivo**
|
||||||
|
...
|
||||||
|
|
||||||
|
**Trabajo realizado**
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**Cambios relevantes**
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**Bloqueos**
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**Decisiones**
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**Próximos pasos**
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**Resumen**
|
||||||
|
...
|
||||||
|
|
||||||
|
Returns string formateado.
|
||||||
|
"""
|
||||||
|
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||||
|
date_range = f"{started}–{ended}"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"## {date_range}",
|
||||||
|
"",
|
||||||
|
"**Objetivo**",
|
||||||
|
f"{session.objective or 'No especificado'}",
|
||||||
|
"",
|
||||||
|
"**Trabajo realizado**",
|
||||||
|
]
|
||||||
|
|
||||||
|
if session.work_done:
|
||||||
|
for item in session.work_done:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin trabajo registrado")
|
||||||
|
|
||||||
|
lines.extend(["", "**Cambios relevantes**"])
|
||||||
|
if session.changes:
|
||||||
|
for item in session.changes:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin cambios")
|
||||||
|
|
||||||
|
lines.extend(["", "**Bloqueos**"])
|
||||||
|
if session.blockers:
|
||||||
|
for item in session.blockers:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin bloqueos")
|
||||||
|
|
||||||
|
lines.extend(["", "**Decisiones**"])
|
||||||
|
if session.decisions:
|
||||||
|
for item in session.decisions:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin decisiones")
|
||||||
|
|
||||||
|
lines.extend(["", "**Próximos pasos**"])
|
||||||
|
if session.next_steps:
|
||||||
|
for item in session.next_steps:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin pasos definidos")
|
||||||
|
|
||||||
|
lines.extend(["", "**Resumen**", summary])
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
|
def format_session_file(self, session: Session) -> str:
|
||||||
|
"""Formatea archivo de sesion detalle en sessions/YYYY-MM-DD_HHMM.md.
|
||||||
|
|
||||||
|
Formato:
|
||||||
|
# Sesion: 2026-03-23 10:00–11:20
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
...
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
...
|
||||||
|
|
||||||
|
## Trabajo realizado
|
||||||
|
...
|
||||||
|
|
||||||
|
## Cambios
|
||||||
|
...
|
||||||
|
|
||||||
|
## Decisiones
|
||||||
|
...
|
||||||
|
|
||||||
|
## Bloqueos
|
||||||
|
...
|
||||||
|
|
||||||
|
## Proximos pasos
|
||||||
|
...
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
...
|
||||||
|
|
||||||
|
## Duracion
|
||||||
|
X minutos
|
||||||
|
"""
|
||||||
|
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Sesion: {started}–{ended}",
|
||||||
|
"",
|
||||||
|
"## Objetivo",
|
||||||
|
f"{session.objective or 'No especificado'}",
|
||||||
|
"",
|
||||||
|
"## Notas",
|
||||||
|
]
|
||||||
|
|
||||||
|
if session.raw_notes:
|
||||||
|
for note in session.raw_notes:
|
||||||
|
note_type = note.get("type", "work")
|
||||||
|
note_text = note.get("text", "")
|
||||||
|
lines.append(f"- [{note_type}] {note_text}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin notas")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Trabajo realizado",
|
||||||
|
])
|
||||||
|
if session.work_done:
|
||||||
|
for item in session.work_done:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin trabajo realizado")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Cambios",
|
||||||
|
])
|
||||||
|
if session.changes:
|
||||||
|
for item in session.changes:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin cambios")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Decisiones",
|
||||||
|
])
|
||||||
|
if session.decisions:
|
||||||
|
for item in session.decisions:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin decisiones")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Bloqueos",
|
||||||
|
])
|
||||||
|
if session.blockers:
|
||||||
|
for item in session.blockers:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin bloqueos")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Proximos pasos",
|
||||||
|
])
|
||||||
|
if session.next_steps:
|
||||||
|
for item in session.next_steps:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin pasos definidos")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Referencias",
|
||||||
|
])
|
||||||
|
if session.references:
|
||||||
|
for item in session.references:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
else:
|
||||||
|
lines.append("- Sin referencias")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"## Duracion",
|
||||||
|
f"{session.duration_minutes} minutos",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
def format_autogen_section(self, content: str, section: str, new_content: str) -> str:
|
||||||
|
"""Reemplaza o inserta una seccion AUTOGEN en contenido Markdown.
|
||||||
|
|
||||||
|
Busca <!-- 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
|
||||||
153
tracker/templates/__init__.py
Normal file
153
tracker/templates/__init__.py
Normal 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,
|
||||||
|
}
|
||||||
1
tracker/utils/__init__.py
Normal file
1
tracker/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utility functions."""
|
||||||
87
tracker/utils/path.py
Normal file
87
tracker/utils/path.py
Normal 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
13
tracker/utils/slug.py
Normal 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
91
tracker/utils/time.py
Normal 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")
|
||||||
Reference in New Issue
Block a user