#!/usr/bin/env python3 """agenticstats — Weekly-style developer activity report.""" import argparse import json import re import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path DEV_DIR = Path.home() / "dev" CLAUDE_DIR = Path.home() / ".claude" / "projects" / "-home-krisyotam" USER = "Kris Yotam" # ── time ranges ────────────────────────────────────────────────────── def get_range(mode): """Return (start_dt, end_dt, label) for the given mode.""" now = datetime.now(timezone.utc) today = now.replace(hour=0, minute=0, second=0, microsecond=0) if mode == "d": start = today label = now.strftime("%b %d, %Y") return start, now, f"Today — {label}" elif mode == "w": weekday = today.weekday() start = today - timedelta(days=weekday) end_day = start + timedelta(days=6) if start.month == end_day.month: label = f"{start.strftime('%b %d')}–{end_day.strftime('%d, %Y')}" else: label = f"{start.strftime('%b %d')} – {end_day.strftime('%b %d, %Y')}" return start, now, f"Week of {start.strftime('%b %d')}" elif mode == "m": start = today.replace(day=1) label = now.strftime("%B %Y") return start, now, label elif mode == "y": start = today.replace(month=1, day=1) label = now.strftime("%Y") return start, now, label elif mode == "a": start = datetime(2020, 1, 1, tzinfo=timezone.utc) return start, now, "All Time" # ── git helpers ────────────────────────────────────────────────────── def ts_fmt(dt): return dt.strftime("%Y-%m-%dT%H:%M:%SZ") def find_repos(dev_dir): repos = [] if not dev_dir.is_dir(): return repos for d in sorted(dev_dir.iterdir()): if d.is_dir() and (d / ".git").exists(): repos.append(d) return repos def git_stats(repo, since, until): """Return (commits, insertions, deletions, is_solo) for a repo in range.""" since_s, until_s = ts_fmt(since), ts_fmt(until) try: r = subprocess.run( ["git", "-C", str(repo), "log", f"--since={since_s}", f"--until={until_s}", "--oneline"], capture_output=True, text=True, timeout=10) commits = len(r.stdout.strip().splitlines()) if r.stdout.strip() else 0 except Exception: return 0, 0, 0, True if commits == 0: return 0, 0, 0, True # LOC via numstat insertions = deletions = 0 try: r = subprocess.run( ["git", "-C", str(repo), "log", f"--since={since_s}", f"--until={until_s}", "--pretty=tformat:", "--numstat"], capture_output=True, text=True, timeout=30) for line in r.stdout.strip().splitlines(): parts = line.split() if len(parts) >= 2: try: insertions += int(parts[0]) except ValueError: pass try: deletions += int(parts[1]) except ValueError: pass except Exception: pass # Solo check try: r = subprocess.run( ["git", "-C", str(repo), "log", f"--since={since_s}", f"--until={until_s}", "--format=%aN"], capture_output=True, text=True, timeout=10) is_solo = len(set(r.stdout.strip().splitlines())) <= 1 except Exception: is_solo = True return commits, insertions, deletions, is_solo # ── AI session stats ───────────────────────────────────────────────── def count_ai_sessions(claude_dir, since, until): cc = gpt = 0 if not claude_dir.is_dir(): return cc, gpt for f in claude_dir.glob("*.jsonl"): try: with open(f) as fh: first = fh.readline().strip() if not first: continue data = json.loads(first) ts_str = data.get("timestamp") if not ts_str: continue ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) if since <= ts <= until: if "[ChatGPT Import]" in data.get("summary", ""): gpt += 1 else: cc += 1 except Exception: continue return cc, gpt # ── per-commit stats for ship + top work ───────────────────────────── def get_commit_stats(repos, since, until): """Return list of (insertions+deletions, files, msg, repo_name) per commit.""" since_s, until_s = ts_fmt(since), ts_fmt(until) results = [] for repo in repos: try: # Get each commit hash + message r = subprocess.run( ["git", "-C", str(repo), "log", f"--since={since_s}", f"--until={until_s}", "--format=%H\t%s"], capture_output=True, text=True, timeout=15) if not r.stdout.strip(): continue for line in r.stdout.strip().splitlines(): parts = line.split("\t", 1) if len(parts) < 2: continue sha, msg = parts if msg.startswith("Merge"): continue # Get numstat for this specific commit r2 = subprocess.run( ["git", "-C", str(repo), "diff", "--numstat", f"{sha}~1", sha], capture_output=True, text=True, timeout=10) ins = dels = files = 0 for sl in r2.stdout.strip().splitlines(): sp = sl.split() if len(sp) >= 3: files += 1 try: ins += int(sp[0]) except ValueError: pass try: dels += int(sp[1]) except ValueError: pass results.append((ins + dels, files, ins, dels, msg, repo.name)) except Exception: continue results.sort(key=lambda x: x[0], reverse=True) return results # ── streak calculation ─────────────────────────────────────────────── def get_shipping_streak(repos): today = datetime.now(timezone.utc).date() day = today streak = 0 while True: day_start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc) day_end = day_start + timedelta(days=1) has_commit = False for repo in repos: try: r = subprocess.run( ["git", "-C", str(repo), "log", f"--since={ts_fmt(day_start)}", f"--until={ts_fmt(day_end)}", "--oneline", "-1"], capture_output=True, text=True, timeout=5) if r.stdout.strip(): has_commit = True break except Exception: continue if has_commit: streak += 1 day -= timedelta(days=1) else: break return streak # ── formatting ─────────────────────────────────────────────────────── def fmt_loc(n): abs_n = abs(n) if abs_n >= 1_000_000: s = f"{abs_n/1_000_000:.1f}M" elif abs_n >= 1_000: s = f"{abs_n/1_000:.1f}k" else: s = str(abs_n) return s def fmt_net(n): sign = "+" if n >= 0 else "-" return f"{sign}{fmt_loc(n)}" GREY = "\033[38;5;240m" WHITE = "\033[97m" DIM = "\033[2m" BOLD = "\033[1m" RESET = "\033[0m" FIRE = "\U0001f525" _ansi_re = re.compile(r'\033\[[0-9;]*m') def strip_ansi(s): return _ansi_re.sub('', s) def box_line(text, width=78): vis_len = len(strip_ansi(text)) padding = max(0, width - vis_len - 4) return f" {GREY}\u2502{RESET} {text}{' ' * padding} {GREY}\u2502{RESET}" def box_top(width=78): return f" {GREY}\u250c{'\u2500' * (width - 2)}\u2510{RESET}" def box_bottom(width=78): return f" {GREY}\u2514{'\u2500' * (width - 2)}\u2518{RESET}" def box_sep(width=78): return f" {GREY}\u251c{'\u2500' * (width - 2)}\u2524{RESET}" # ── rendering ──────────────────────────────────────────────────────── def render(mode, since, until, label): repos = find_repos(DEV_DIR) # Per-repo stats project_stats = [] total_commits = total_ins = total_del = 0 for repo in repos: commits, ins, dels, solo = git_stats(repo, since, until) if commits > 0: project_stats.append((repo.name, commits, ins, dels, solo)) total_commits += commits total_ins += ins total_del += dels project_stats.sort(key=lambda x: x[1], reverse=True) active = len(project_stats) # AI sessions cc_sessions, gpt_sessions = count_ai_sessions(CLAUDE_DIR, since, until) total_sessions = cc_sessions + gpt_sessions # Streak streak = get_shipping_streak(repos) # Per-commit stats for ship + top work commit_stats = get_commit_stats(repos, since, until) # ── output ── period_map = {"d": "Day", "w": "Week", "m": "Month", "y": "Year", "a": "All Time"} ship_map = {"d": "SHIP OF THE DAY", "w": "SHIP OF THE WEEK", "m": "SHIP OF THE MONTH", "y": "SHIP OF THE YEAR", "a": "BIGGEST SHIP"} print() print(f" {BOLD}{WHITE}\u2728 Your {period_map[mode]}: {USER} \u2014 {label}{RESET}") print() print(box_top()) print(box_line(f"{BOLD}{WHITE}{USER.upper()} \u2014 {label}{RESET}")) print(box_sep()) print(box_line("")) net = total_ins - total_del print(box_line(f" {total_commits} commits across {active} projects")) print(box_line(f" +{fmt_loc(total_ins)} LOC added \u00b7 {fmt_loc(total_del)} LOC deleted \u00b7 {fmt_net(net)} net")) if total_sessions > 0: parts = [] if cc_sessions > 0: parts.append(f"CC: {cc_sessions}") if gpt_sessions > 0: parts.append(f"GPT: {gpt_sessions}") print(box_line(f" {total_sessions} AI coding sessions ({', '.join(parts)})")) if streak > 0: print(box_line(f" {streak}-day shipping streak {FIRE}")) print(box_line("")) print(box_sep()) print(box_line(f" {BOLD}PROJECTS{RESET}")) print(box_line("")) if project_stats: name_w = max(max(len(p[0]) for p in project_stats[:15]), 12) for name, commits, ins, dels, solo in project_stats[:15]: tag = "solo" if solo else "team" net_loc = ins - dels line = f" {name:<{name_w}} {commits:>4} commits {fmt_net(net_loc):>9} LOC {tag}" print(box_line(line)) else: print(box_line(" No commits in this period.")) print(box_line("")) print(box_sep()) print(box_line(f" {BOLD}{ship_map[mode]}{RESET}")) if commit_stats: total_loc, files, ins, dels, msg, repo_name = commit_stats[0] if len(msg) > 60: msg = msg[:57] + "..." print(box_line(f" {msg}")) print(box_line(f" \u2014 {fmt_loc(total_loc)} lines across {files} files ({repo_name})")) else: print(box_line(" Nothing shipped yet.")) print(box_line("")) print(box_sep()) print(box_line(f" {BOLD}TOP WORK{RESET}")) if commit_stats: seen = set() count = 0 for _, _, _, _, msg, rn in commit_stats: if msg in seen: continue seen.add(msg) if len(msg) > 66: msg = msg[:63] + "..." print(box_line(f" \u2022 {msg}")) count += 1 if count >= 5: break else: print(box_line(" No notable commits in this period.")) print(box_line("")) print(box_sep()) print(box_line(f" {DIM}Powered by agenticstats{RESET}")) print(box_bottom()) print() # ── CLI ────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( prog="agenticstats", description="Developer activity report." ) group = parser.add_mutually_exclusive_group() group.add_argument("-d", action="store_true", help="Daily (today)") group.add_argument("-w", action="store_true", help="Weekly (default)") group.add_argument("-m", action="store_true", help="Monthly") group.add_argument("-y", action="store_true", help="Yearly") group.add_argument("-a", action="store_true", help="All time") args = parser.parse_args() if args.d: mode = "d" elif args.m: mode = "m" elif args.y: mode = "y" elif args.a: mode = "a" else: mode = "w" since, until, label = get_range(mode) render(mode, since, until, label) if __name__ == "__main__": main()