b485cee634
Five minutes with two projects open was counted as five for each, so Wednesday's columns summed to 18h against 13h10 of wall clock and no column said which number to trust. Each slot's minutes are now divided evenly among the projects live in it, largest remainder over whole minutes, so the columns add up to the total exactly. That ratio is what a submission scales the user's stated day length by. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
11 KiB
Python
Executable File
329 lines
11 KiB
Python
Executable File
#!/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:<top-3-dirs>'."""
|
|
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())
|