Files
agent-skills/skills/hourlog/scripts/scan-activity.py
T
naps62 b485cee634 feat: split overlapping minutes between projects
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>
2026-08-14 22:05:13 +00:00

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())