diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/.gitkeep b/examples/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/projects/.gitkeep b/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f61ac42 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..46816dd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tracker/cli/__init__.py b/tracker/cli/__init__.py new file mode 100644 index 0000000..f5d621c --- /dev/null +++ b/tracker/cli/__init__.py @@ -0,0 +1 @@ +"""CLI commands for the tracker.""" diff --git a/tracker/cli/commands.py b/tracker/cli/commands.py new file mode 100644 index 0000000..34240a3 --- /dev/null +++ b/tracker/cli/commands.py @@ -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 ") + 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 ", 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} + + +Status: {project.status} + + +## 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_ + + + + +## Recent Sessions + + + + +_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", +] diff --git a/tracker/cli/main.py b/tracker/cli/main.py new file mode 100644 index 0000000..99e4f10 --- /dev/null +++ b/tracker/cli/main.py @@ -0,0 +1,7 @@ +"""Main CLI entry point.""" +import typer + +app = typer.Typer( + name="tracker", + help="Personal project tracker CLI", +) diff --git a/tracker/templates/__init__.py b/tracker/templates/__init__.py new file mode 100644 index 0000000..8b73e0c --- /dev/null +++ b/tracker/templates/__init__.py @@ -0,0 +1 @@ +"""Templates package.""" diff --git a/tracker/utils/__init__.py b/tracker/utils/__init__.py new file mode 100644 index 0000000..f5d3dc7 --- /dev/null +++ b/tracker/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions.""" diff --git a/tracker/utils/path.py b/tracker/utils/path.py new file mode 100644 index 0000000..32eb46c --- /dev/null +++ b/tracker/utils/path.py @@ -0,0 +1 @@ +"""Path utility functions.""" diff --git a/tracker/utils/slug.py b/tracker/utils/slug.py new file mode 100644 index 0000000..b74b3de --- /dev/null +++ b/tracker/utils/slug.py @@ -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(" ", "-") diff --git a/tracker/utils/time.py b/tracker/utils/time.py new file mode 100644 index 0000000..5992470 --- /dev/null +++ b/tracker/utils/time.py @@ -0,0 +1 @@ +"""Time utility functions."""