#!/usr/bin/env python3 """Bucket Claude Code and Codex session activity into days per project. Usage: scan-activity.py --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] scan-activity.py --week last|this Reads ~/.config/hourlog/projects.json for the path-prefix -> project mapping (see config.example.json). The mapping is machine-local on purpose: this repo is public and client names are not. The unit is an "active slot" — a 5-minute window in which at least one message was written. Slots are a set per (day, project), so a swarm of 40 subagent transcripts on one project counts once, and two projects worked in parallel each keep their own slots. Message counts would make one overnight autonomous run outweigh a real morning; wall-clock presence does not. Output is a markdown table: one row per day, one column per project, each cell holding the time that project was actually active. Nothing is extrapolated and nothing is fitted to a nominal day — some days start early, some run late, and a guessed number is worse than a small true one. Measured time is a floor. Meetings, review and thinking leave no transcript, so the user adds those back; the script never does. Overlap is divided, not double-counted: five minutes with two projects open is five minutes of the day, half to each. So the project columns add up to the 'total' column, which is wall-clock presence. """ import argparse import collections import datetime as dt import glob import json import os import sys from zoneinfo import ZoneInfo CONFIG = os.path.expanduser("~/.config/hourlog/projects.json") DEFAULTS = { "timezone": "Europe/Lisbon", "slot_minutes": 5, "day_start_hour": 8, "day_end_hour": 20, "projects": [], "exclude": [], } def load_config(path): cfg = dict(DEFAULTS) if os.path.exists(path): with open(path) as fh: cfg.update(json.load(fh)) else: print(f"no config at {path} — every path will land in 'unmapped'", file=sys.stderr) # Longest prefix wins, so a worktree dir can override its parent. rules = [] for p in cfg["projects"]: for m in p["match"]: rules.append((os.path.expanduser(m).rstrip("/"), p["name"])) for m in cfg["exclude"]: rules.append((os.path.expanduser(m).rstrip("/"), None)) cfg["_rules"] = sorted(rules, key=lambda r: -len(r[0])) return cfg def classify(cwd, rules): """-> project name, or None if excluded, or 'unmapped:'.""" if not cwd: return "unmapped:?" for prefix, name in rules: if cwd == prefix or cwd.startswith(prefix + "/"): return name short = cwd.replace(os.path.expanduser("~"), "~") return "unmapped:" + "/".join(short.split("/")[:3]) def claude_files(root, cutoff): for f in glob.glob(os.path.join(root, "*", "*.jsonl")): try: if os.stat(f).st_mtime >= cutoff: yield f except OSError: continue def codex_files(root, cutoff): for f in glob.glob(os.path.join(root, "*", "*", "*", "*.jsonl")): try: if os.stat(f).st_mtime >= cutoff: yield f except OSError: continue def read_session(path): """-> (cwd, [timestamp strings]). Handles both Claude and Codex layouts.""" cwd, stamps = None, [] try: fh = open(path, errors="replace") except OSError: return None, [] with fh: for line in fh: try: d = json.loads(line) except ValueError: continue if cwd is None: cwd = d.get("cwd") or (d.get("payload") or {}).get("cwd") ts = d.get("timestamp") if ts: stamps.append(ts) return cwd, stamps def attribute(slots_of, owners, mins): """Divide each slot's minutes evenly among the projects live in it. Five minutes with two projects open is five minutes of the user's day, not ten, so each project gets half. Largest remainder over whole minutes keeps the parts summing to wall clock exactly. """ exact = {p: sum(mins / owners[s] for s in ss) for p, ss in slots_of.items()} floors = {p: int(v) for p, v in exact.items()} left = round(sum(exact.values())) - sum(floors.values()) for p in sorted(exact, key=lambda p: -(exact[p] - floors[p]))[:left]: floors[p] += 1 return floors def fmt_dur(minutes): """45 -> '45min', 155 -> '2h35', 180 -> '3h', 0 -> '—'.""" if not minutes: return "—" if minutes < 60: return f"{minutes}min" h, m = divmod(minutes, 60) return f"{h}h" if not m else f"{h}h{m:02d}" def fmt_hour_list(hours): """[0,7,20,21,22] -> '00h, 07h, 20-22h'.""" out, run = [], [] for h in sorted(set(hours)) + [None]: if run and h == run[-1] + 1: run.append(h) continue if run: out.append(f"{run[0]:02d}h" if len(run) == 1 else f"{run[0]:02d}-{run[-1]:02d}h") run = [h] if h is not None else [] return ", ".join(out) def md_table(rows, align_right=()): """Render rows (first is the header) as a padded markdown table.""" w = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] def line(cells): return "| " + " | ".join( c.rjust(w[i]) if i in align_right else c.ljust(w[i]) for i, c in enumerate(cells)) + " |" sep = "|" + "|".join( ("-" * (w[i] + 1) + ":") if i in align_right else ("-" * (w[i] + 2)) for i in range(len(w))) + "|" return "\n".join([line(rows[0]), sep] + [line(r) for r in rows[1:]]) def main(): ap = argparse.ArgumentParser() ap.add_argument("--since") ap.add_argument("--until") ap.add_argument("--week", choices=["last", "this"]) ap.add_argument("--config", default=CONFIG) ap.add_argument("--json", action="store_true") a = ap.parse_args() cfg = load_config(a.config) tz = ZoneInfo(cfg["timezone"]) today = dt.datetime.now(tz).date() if a.week: monday = today - dt.timedelta(days=today.weekday()) if a.week == "last": monday -= dt.timedelta(days=7) since, until = monday, monday + dt.timedelta(days=6) elif a.since: since = dt.date.fromisoformat(a.since) until = dt.date.fromisoformat(a.until) if a.until else today else: ap.error("need --since or --week") start = dt.datetime.combine(since, dt.time(0), tz) end = dt.datetime.combine(until, dt.time(23, 59, 59), tz) slot = cfg["slot_minutes"] * 60 # date -> project -> hour -> set of slot indices grid = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(set))) sources = [ (os.path.expanduser("~/.claude/projects"), claude_files), (os.path.expanduser("~/.codex/sessions"), codex_files), ] nfiles = 0 for root, lister in sources: if not os.path.isdir(root): continue for f in lister(root, start.timestamp()): cwd, stamps = read_session(f) proj = classify(cwd, cfg["_rules"]) if proj is None: continue hit = False for ts in stamps: try: t = dt.datetime.fromisoformat( ts.replace("Z", "+00:00")).astimezone(tz) except ValueError: continue if not (start <= t <= end): continue hit = True grid[t.date().isoformat()][proj][t.hour].add( int(t.timestamp()) // slot) nfiles += hit del stamps mins = cfg["slot_minutes"] report = [] d = since while d <= until: per = grid.get(d.isoformat(), {}) slots_of = {p: {s for ss in hrs.values() for s in ss} for p, hrs in per.items() if not p.startswith("unmapped:")} owners = collections.Counter() for ss in slots_of.values(): owners.update(ss) share = attribute(slots_of, owners, mins) entry = { "date": d.isoformat(), "weekday": d.strftime("%a"), "active_minutes": len(owners) * mins, "projects": [], } raw = {p: sum(len(s) for s in hrs.values()) * mins for p, hrs in per.items()} for proj in sorted(per, key=lambda p: -raw[p]): hrs = sorted(per[proj]) entry["projects"].append({ "project": proj, "active_minutes": share.get(proj, raw[proj]), "raw_minutes": raw[proj], "first_hour": hrs[0], "last_hour": hrs[-1], "outside_workday": [ h for h in hrs if h < cfg["day_start_hour"] or h >= cfg["day_end_hour"] ], }) report.append(entry) d += dt.timedelta(days=1) unmapped = sorted( {p for e in report for p in (x["project"] for x in e["projects"]) if p.startswith("unmapped:")}) if a.json: json.dump({"since": since.isoformat(), "until": until.isoformat(), "slot_minutes": mins, "sessions": nfiles, "unmapped": unmapped, "report": report}, sys.stdout, indent=1) print() return 0 print(f"{since} .. {until} — {nfiles} sessions, {mins}-min slots, " f"{cfg['timezone']}") print("measured active time, nothing extrapolated; " "overlapping minutes split evenly between projects\n") cols = [p for p in dict.fromkeys( x["project"] for e in report for x in e["projects"]) if not p.startswith("unmapped:")] if not cols: print("no mapped project activity in this range") return 0 rows = [["day"] + cols + ["total", "window", "flags"]] week = collections.Counter() week_total = 0 for e in report: if not e["projects"]: continue by = {p["project"]: p for p in e["projects"]} cells = [] for c in cols: p = by.get(c) cells.append(fmt_dur(p["active_minutes"]) if p else "—") if p: week[c] += p["active_minutes"] week_total += e["active_minutes"] hrs = [h for p in e["projects"] for h in (p["first_hour"], p["last_hour"])] window = f"{min(hrs):02d}-{max(hrs):02d}h" if hrs else "" flags = [] outside = sorted({h for p in e["projects"] for h in p["outside_workday"]}) if outside: flags.append("outside " + fmt_hour_list(outside)) if any(p["project"].startswith("unmapped:") for p in e["projects"]): flags.append("unmapped time excluded") rows.append([f"{e['date']} {e['weekday']}"] + cells + [fmt_dur(e["active_minutes"]), window, "; ".join(flags)]) rows.append(["**total**"] + [f"**{fmt_dur(week[c])}**" for c in cols] + [f"**{fmt_dur(week_total)}**", "", ""]) right = set(range(1, len(cols) + 2)) print(md_table(rows, align_right=right)) if unmapped: print("\nunmapped paths — add them to the config or the exclude list:") for u in unmapped: print(" ", u) return 0 if __name__ == "__main__": sys.exit(main())