#!/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()