Skip to main content

#!/usr/bin/env python3 """ Session Log Manager - Consolidated daily session logging with ISO timestamps.

Creates and appends to daily session logs (UTC-based), tracks session UUIDs, and auto-classifies after updates.

ADR-155: Supports project-scoped session logs for multi-project workflows.

Usage: python3 session_log_manager.py append "Fixed cart API" --issue "..." --fix "..." python3 session_log_manager.py new-session "Commerce API Fixes" python3 session_log_manager.py view --date today python3 session_log_manager.py view --date 2025-12-30

Project Scoping (ADR-155): python3 session_log_manager.py append "FPA work" --project CUST-avivatec-fpa python3 session_log_manager.py list --project PILOT """

import argparse import json import os import re import subprocess import sys import uuid from datetime import datetime, timezone from pathlib import Path from typing import Optional

Add parent to path for imports

sys.path.insert(0, str(Path(file).parent.parent))

Import project discovery functions (ADR-144, ADR-155)

try: from scripts.core.paths import ( discover_project, get_machine_uuid, get_user_data_dir, get_session_logs_dir, discover_projects_dir, ) HAS_PROJECT_SUPPORT = True except ImportError: HAS_PROJECT_SUPPORT = False

def discover_project() -> Optional[str]:
return os.environ.get('CODITECT_PROJECT')

def get_machine_uuid() -> Optional[str]:
return None

def get_user_data_dir() -> Path:
return Path.home() / "PROJECTS" / ".coditect-data"

def get_session_logs_dir() -> Path:
return get_user_data_dir() / "session-logs"

def discover_projects_dir() -> Path:
return Path.home() / "PROJECTS"

def get_utc_now() -> datetime: """Get current UTC datetime.""" return datetime.now(timezone.utc)

def get_iso_timestamp() -> str: """Get ISO 8601 timestamp in UTC.""" return get_utc_now().strftime("%Y-%m-%dT%H:%M:%SZ")

def get_utc_date() -> str: """Get current UTC date as YYYY-MM-DD.""" return get_utc_now().strftime("%Y-%m-%d")

def find_coditect_root() -> Path: """Find the coditect-rollout-master root directory.""" current = Path.cwd()

# Walk up looking for rollout-master or .coditect
for parent in [current] + list(current.parents):
if (parent / "submodules" / "core" / "coditect-core").exists():
return parent
if parent.name == "coditect-rollout-master":
return parent
if (parent / ".coditect").exists():
return parent

# Fallback to environment or default
env_root = os.environ.get("CODITECT_ROOT")
if env_root:
return Path(env_root)

return Path("/Users/halcasteel/PROJECTS/coditect-rollout-master")

def get_log_dir(project_id: Optional[str] = None) -> Path: """ Get the session logs directory, creating if needed.

ADR-155: Project-Scoped Session Logs
- If project_id is provided, returns project-scoped directory
- If project can be auto-detected, uses project-scoped directory
- Otherwise, falls back to legacy machine-scoped directory
"""
# Try to get project context
if project_id is None:
project_id = discover_project()

# Get machine UUID for directory structure
machine_uuid = get_machine_uuid()
if not machine_uuid:
machine_uuid = "default-machine"

if project_id:
# Project-scoped path (ADR-155)
log_dir = get_session_logs_dir() / "projects" / project_id / machine_uuid
else:
# Legacy: Check if we're in rollout-master and use docs/session-logs
root = find_coditect_root()
legacy_dir = root / "docs" / "session-logs"
if legacy_dir.exists() or root.exists():
log_dir = legacy_dir
else:
# Fallback to machine-scoped in .coditect-data
log_dir = get_session_logs_dir() / machine_uuid

log_dir.mkdir(parents=True, exist_ok=True)
return log_dir

def get_log_path(date: Optional[str] = None, project_id: Optional[str] = None) -> Path: """ Get path to session log for given date (default: today UTC).

Args:
date: Date string YYYY-MM-DD or 'today'
project_id: Optional project ID for project-scoped logs
"""
if date is None or date == "today":
date = get_utc_date()
return get_log_dir(project_id) / f"SESSION-LOG-{date}.md"

def generate_session_id() -> str: """Generate a new session UUID.""" return str(uuid.uuid4())

def create_frontmatter(date: str, project_id: Optional[str] = None) -> str: """ Create frontmatter for a new session log.

Args:
date: Date string YYYY-MM-DD
project_id: Optional project ID for project-scoped logs (ADR-155)
"""
timestamp = get_iso_timestamp()
project_line = f"\nproject_id: '{project_id}'" if project_id else ""
project_header = f" [{project_id}]" if project_id else ""

return f"""---

title: Session Log {date}{project_header} type: session-log component_type: session-log version: 1.0.0 audience: contributor status: active summary: Development session log for {date}{' - Project: ' + project_id if project_id else ''} keywords:

  • session-log
  • development-history{project_line} tokens: ~1000 created: '{timestamp}' updated: '{timestamp}' tags:
  • session-log sessions: [] moe_confidence: 0.900 moe_classified: {date}

Session Log - {date}{project_header}

Consolidated development session log. All timestamps are ISO 8601 UTC. {f'Project: {project_id}' if project_id else ''}


"""

def update_frontmatter_timestamp(content: str) -> str: """Update the 'updated' timestamp in frontmatter.""" timestamp = get_iso_timestamp() # Match updated: 'YYYY-MM-DDTHH:MM:SSZ' or updated: "YYYY-MM-DDTHH:MM:SSZ" pattern = r"(updated:\s*['"])(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)(['"])" replacement = f"\g<1>{timestamp}\g<3>" return re.sub(pattern, replacement, content)

def add_session_to_frontmatter(content: str, session_id: str, focus: str) -> str: """Add a new session to the frontmatter sessions list.""" timestamp = get_iso_timestamp() # Find sessions: [] or sessions: with entries if "sessions: []" in content: new_sessions = f"""sessions:

  • id: '{session_id}' start: '{timestamp}' focus: '{focus}'""" content = content.replace("sessions: []", new_sessions) else: # Append to existing sessions list session_entry = f"""- id: '{session_id}' start: '{timestamp}' focus: '{focus}'""" # Find the last session entry and append after it pattern = r"(- id: '[^']+'\n start: '[^']+'\n focus: '[^']+')" matches = list(re.finditer(pattern, content)) if matches: last_match = matches[-1] insert_pos = last_match.end() content = content[:insert_pos] + "\n" + session_entry + content[insert_pos:] else: # No sessions found, add after sessions: content = content.replace("sessions:", f"sessions:\n{session_entry}")

    return update_frontmatter_timestamp(content)

def new_session(focus: str, classify: bool = True, project_id: Optional[str] = None) -> str: """ Start a new session in today's log.

Args:
focus: Session focus description
classify: Run auto-classification after update
project_id: Optional project ID for project-scoped logs (ADR-155)
"""
log_path = get_log_path(project_id=project_id)
session_id = generate_session_id()
timestamp = get_iso_timestamp()

# Resolve project for display
effective_project = project_id or discover_project()

# Create or read existing log
if log_path.exists():
content = log_path.read_text()
else:
content = create_frontmatter(get_utc_date(), project_id=effective_project)

# Add session to frontmatter
content = add_session_to_frontmatter(content, session_id, focus)

# Add session header to content
project_line = f"\n**Project:** {effective_project}" if effective_project else ""
session_header = f"""

{timestamp} - Session Start

Session ID: {session_id} Focus: {focus}{project_line}


""" content += session_header

# Write updated log
log_path.write_text(content)

# Auto-classify
if classify:
run_classify(log_path)

print(f"Started new session: {session_id}")
print(f"Focus: {focus}")
if effective_project:
print(f"Project: {effective_project}")
print(f"Log: {log_path}")

return session_id

def append_entry( message: str, issue: Optional[str] = None, root_cause: Optional[str] = None, fix: Optional[str] = None, files: Optional[str] = None, deployed: Optional[str] = None, tasks: Optional[str] = None, author: Optional[str] = None, classify: bool = True, project_id: Optional[str] = None ) -> None: """ Append an entry to today's session log.

Args:
message: Brief description of the task/fix
issue: Description of the problem
root_cause: Root cause analysis
fix: What was done to fix it
files: Comma-separated list of modified files
deployed: Deployment version tag
tasks: Task IDs per ADR-054 (e.g., "A.9.1.1" or "F.4.1.1-F.4.4.1")
author: LLM author attribution (e.g., "Claude (Opus 4.5)")
classify: Run auto-classification after update
project_id: Optional project ID for project-scoped logs (ADR-155)
"""
# Resolve project for path and display
effective_project = project_id or discover_project()

log_path = get_log_path(project_id=effective_project)
timestamp = get_iso_timestamp()

# Create log if it doesn't exist
if not log_path.exists():
# Create with a default session
content = create_frontmatter(get_utc_date(), project_id=effective_project)
session_id = generate_session_id()
content = add_session_to_frontmatter(content, session_id, "Development Session")
project_line = f"\n**Project:** {effective_project}" if effective_project else ""
content += f"""

{timestamp} - Session Start

Session ID: {session_id} Focus: Development Session{project_line}


""" log_path.write_text(content)

# Read existing content
content = log_path.read_text()

# Build entry header with optional task IDs
if tasks:
entry_header = f"### {timestamp} - [{tasks}] {message}"
else:
entry_header = f"### {timestamp} - {message}"

entry_lines = [entry_header, ""]

# Add author attribution (required per CLAUDE.md)
if author:
entry_lines.append(f"**Author:** {author}")
entry_lines.append("")

if tasks:
entry_lines.append(f"- **Task(s):** {tasks}")
if issue:
entry_lines.append(f"- **Issue:** {issue}")
if root_cause:
entry_lines.append(f"- **Root Cause:** {root_cause}")
if fix:
entry_lines.append(f"- **Fix:** {fix}")
if files:
entry_lines.append("- **Files Modified:**")
for f in files.split(","):
entry_lines.append(f" - `{f.strip()}`")
if deployed:
entry_lines.append(f"- **Deployed:** `{deployed}`")

# If no details, add the message as description
if not any([issue, root_cause, fix, files, deployed, tasks]):
entry_lines.append(message)

entry_lines.append("")
entry_lines.append("---")
entry_lines.append("")

entry = "\n".join(entry_lines)

# Update frontmatter timestamp
content = update_frontmatter_timestamp(content)

# Append entry
content += entry

# Write updated log
log_path.write_text(content)

# Auto-classify
if classify:
run_classify(log_path)

print(f"Entry added to {log_path.name}")
print(f"Timestamp: {timestamp}")
if effective_project:
print(f"Project: {effective_project}")
print(f"Path: {log_path}")

def view_log(date: Optional[str] = None, project_id: Optional[str] = None) -> None: """ View the session log for a given date.

Args:
date: Date string YYYY-MM-DD or 'today'
project_id: Optional project ID for project-scoped logs (ADR-155)
"""
# Resolve project for path
effective_project = project_id or discover_project()
log_path = get_log_path(date, project_id=effective_project)

if not log_path.exists():
print(f"No session log found for {date or 'today'}")
if effective_project:
print(f"Project: {effective_project}")
print(f"Expected path: {log_path}")
return

print(log_path.read_text())

def run_classify(log_path: Path) -> None: """Run /classify on the session log to update frontmatter.""" coditect_core = find_coditect_root() / "submodules" / "core" / "coditect-core" classify_script = coditect_core / "scripts" / "moe_classifier" / "classify.py"

if not classify_script.exists():
print(f"Warning: Classify script not found at {classify_script}")
return

try:
# Run classify with --update-frontmatter
result = subprocess.run(
[
sys.executable,
str(classify_script),
str(log_path),
"--update-frontmatter",
"-q"
],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
print("Classification updated")
else:
print(f"Classification warning: {result.stderr[:200] if result.stderr else 'unknown'}")
except subprocess.TimeoutExpired:
print("Classification timed out")
except Exception as e:
print(f"Classification error: {e}")

def list_logs(project_id: Optional[str] = None) -> None: """ List available session logs.

Args:
project_id: Optional project ID for project-scoped logs (ADR-155)
"""
# Resolve project for path
effective_project = project_id or discover_project()
log_dir = get_log_dir(effective_project)

logs = sorted(log_dir.glob("SESSION-LOG-*.md"), reverse=True)
if logs:
print("Available session logs:")
if effective_project:
print(f"Project: {effective_project}")
print(f"Directory: {log_dir}")
print()
for log in logs[:10]:
print(f" - {log.name}")
if len(logs) > 10:
print(f" ... and {len(logs) - 10} more")
else:
print("No session logs found")
if effective_project:
print(f"Project: {effective_project}")
print(f"Directory: {log_dir}")

def main(): parser = argparse.ArgumentParser( description="Session Log Manager - Consolidated daily session logging (ADR-155)" ) subparsers = parser.add_subparsers(dest="action", help="Action to perform")

# Common project argument helper
def add_project_arg(subparser: argparse.ArgumentParser) -> None:
"""Add --project argument to a subparser (ADR-155)."""
subparser.add_argument(
"--project", "-p",
help="Project ID for project-scoped logs (auto-detected if not specified)"
)

# Append action
append_parser = subparsers.add_parser("append", help="Append entry to today's log")
append_parser.add_argument("message", help="Brief description of the task/fix")
append_parser.add_argument("--issue", help="Description of the problem")
append_parser.add_argument("--root-cause", help="Root cause analysis")
append_parser.add_argument("--fix", help="What was done to fix it")
append_parser.add_argument("--files", help="Comma-separated list of modified files")
append_parser.add_argument("--deployed", help="Deployment version tag")
append_parser.add_argument("--tasks", "-t", help="Task IDs per ADR-054 (e.g., 'A.9.1.1')")
append_parser.add_argument("--author", "-a", help="LLM author attribution (e.g., 'Claude (Opus 4.5)')")
append_parser.add_argument("--no-classify", action="store_true", help="Skip auto-classification")
add_project_arg(append_parser)

# New session action
session_parser = subparsers.add_parser("new-session", help="Start a new session")
session_parser.add_argument("focus", help="Focus/description of the session")
session_parser.add_argument("--no-classify", action="store_true", help="Skip auto-classification")
add_project_arg(session_parser)

# View action
view_parser = subparsers.add_parser("view", help="View session log")
view_parser.add_argument("--date", default="today", help="Date to view (YYYY-MM-DD or 'today')")
add_project_arg(view_parser)

# List action
list_parser = subparsers.add_parser("list", help="List available session logs")
add_project_arg(list_parser)

args = parser.parse_args()

if args.action == "append":
append_entry(
message=args.message,
issue=args.issue,
root_cause=getattr(args, "root_cause", None),
fix=args.fix,
files=args.files,
deployed=args.deployed,
tasks=getattr(args, "tasks", None),
author=getattr(args, "author", None),
classify=not args.no_classify,
project_id=getattr(args, "project", None)
)
elif args.action == "new-session":
new_session(
focus=args.focus,
classify=not args.no_classify,
project_id=getattr(args, "project", None)
)
elif args.action == "view":
view_log(
date=args.date if args.date != "today" else None,
project_id=getattr(args, "project", None)
)
elif args.action == "list":
list_logs(project_id=getattr(args, "project", None))
else:
parser.print_help()

if name == "main": main()