feat: hourlog skill + Friday timer
Turns Claude Code and Codex session activity into a half-day-per-project proposal, reconciles it against the timesheet API, and submits only what the user approves in-session. Activity is measured in 5-minute active slots, deduplicated per project, not message counts — otherwise one overnight autonomous run outweighs a real morning's work. The path-to-project mapping and the API credentials stay in ~/.config/hourlog/ and ~/.env.claude. This repo is public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
*.bak
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
@@ -70,6 +70,34 @@ systemctl --user list-timers week-review.timer
|
||||
|
||||
Needs `loginctl enable-linger` so the timer runs while logged out. Logs are in `~/.local/state/week-review/run.log`. The nix module deliberately omits the timer for the same one-machine reason.
|
||||
|
||||
## Hour log timer
|
||||
|
||||
`systemd/hourlog.timer` fires Fridays at 16:00 Europe/Lisbon and runs
|
||||
`bin/hourlog-session.sh`, which opens an Agent of Empires session on a scratch
|
||||
dir, sends it `/hourlog --week this`, and pushes an ntfy notification.
|
||||
|
||||
Same shape as the weekly review and interactive for the same reason: the skill
|
||||
proposes hours and stops for approval before writing anything to the timesheet.
|
||||
An unattended run would be deciding a company record on your behalf. It skips
|
||||
if a previous `hourlog-*` session is still open, and `Persistent=true` makes a
|
||||
missed Friday fire on the next boot.
|
||||
|
||||
Enable on one machine only:
|
||||
|
||||
```sh
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now hourlog.timer
|
||||
```
|
||||
|
||||
Setup lives outside this repo, which is public:
|
||||
|
||||
- `~/.config/hourlog/projects.json` — path prefix to project mapping, copied
|
||||
from `skills/hourlog/config.example.json`.
|
||||
- `HOURLOG_API` and `HOURLOG_TOKEN` in `~/.env.claude` — API base URL and a
|
||||
personal access token (`profile:read`, `schedule:read`, `schedule:write`).
|
||||
|
||||
No project, client, or host name belongs in a committed file here.
|
||||
|
||||
## Adding a skill
|
||||
|
||||
Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `assets/`). Commit. Non-Nix: re-run `bin/link.sh`. Nix: rebuild.
|
||||
@@ -85,6 +113,7 @@ Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `asse
|
||||
| `nightshift` | hours-long unattended build; architect delegating to subagents, backs off before the 5h limit |
|
||||
| `linear-common` | shared config/setup/worktree conventions + local verification budget (dependency of work/yolo/blitz/nightshift) |
|
||||
| `week-review` | review the past week's sessions for recurring friction; reads open issues here as carry-over |
|
||||
| `hourlog` | half-day-per-project proposal from session activity, reconciled against the timesheet; submits only what you approve |
|
||||
| `crit`, `improve-codebase-architecture` | misc |
|
||||
|
||||
## Vendored skills
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Friday hour-logging session: create it in Agent of Empires, prompt it with
|
||||
# /hourlog, ping the phone. Interactive, not `-p`: the run stops for approval
|
||||
# before writing to the timesheet. See README "Hour log timer".
|
||||
set -euo pipefail
|
||||
|
||||
PROMPT="${HOURLOG_PROMPT:-/hourlog --week this}"
|
||||
TOPIC="${HOURLOG_NTFY_TOPIC:-homelab}"
|
||||
AOE="${HOURLOG_AOE:-$HOME/.local/bin/aoe}"
|
||||
LOG="$HOME/.local/state/hourlog/run.log"
|
||||
|
||||
WEEK="$(date +%G-W%V)"
|
||||
TITLE="hourlog-$WEEK"
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== $(date -Is) starting $TITLE ==="
|
||||
|
||||
# NTFY_URL/NTFY_TOKEN and HOURLOG_API/HOURLOG_TOKEN live here; ~/.zshrc only
|
||||
# sources it for interactive shells.
|
||||
# shellcheck disable=SC1091
|
||||
[ -f "$HOME/.env.claude" ] && . "$HOME/.env.claude"
|
||||
|
||||
notify() { # notify <title> <priority> <message>
|
||||
[ -n "${NTFY_URL:-}" ] || { echo "no NTFY_URL, skipping notify"; return 0; }
|
||||
curl -sS -m 10 -o /dev/null \
|
||||
-H "Authorization: Bearer ${NTFY_TOKEN:-}" \
|
||||
-H "Title: $1" -H "Priority: $2" -H "Tags: hourglass_flowing_sand" \
|
||||
-d "$3" "$NTFY_URL/$TOPIC" || echo "notify failed"
|
||||
}
|
||||
|
||||
if [ -z "${HOURLOG_TOKEN:-}" ] || [ -z "${HOURLOG_API:-}" ]; then
|
||||
echo "HOURLOG_API/HOURLOG_TOKEN missing from ~/.env.claude"
|
||||
notify "Hour log not configured" high \
|
||||
"HOURLOG_API/HOURLOG_TOKEN missing — the session would stall at setup."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
open="$("$AOE" list 2>/dev/null | awk '$1 ~ /^hourlog-/ { print $1 }' || true)"
|
||||
if [ -n "$open" ]; then
|
||||
echo "already open: $open — not starting a second one"
|
||||
notify "Hour log skipped" default \
|
||||
"An earlier hour log is still open ($open). Finish or remove it."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --scratch keeps the session's cwd under the agent-of-empires app dir, which
|
||||
# the hourlog config excludes — otherwise it lands in next week's scan.
|
||||
"$AOE" add --scratch --title "$TITLE" --cmd claude --yolo --trust-hooks
|
||||
"$AOE" session start "$TITLE"
|
||||
|
||||
# The agent needs its TUI up before it can take a prompt; `send` into a
|
||||
# still-booting pane is dropped silently.
|
||||
sleep 25
|
||||
if "$AOE" send "$TITLE" "$PROMPT"; then
|
||||
echo "session $TITLE launched and prompted"
|
||||
notify "Hour log ready" default "aoe: $TITLE — proposal waiting on your OK"
|
||||
else
|
||||
echo "failed to send prompt to $TITLE"
|
||||
notify "Hour log failed to start" high "session $TITLE — see $LOG"
|
||||
exit 1
|
||||
fi
|
||||
+3
-2
@@ -83,5 +83,6 @@ fi
|
||||
|
||||
echo "done."
|
||||
echo "hooks still need wiring in ~/.claude/settings.json — see hooks/README.md"
|
||||
echo "weekly review timer (one machine only):"
|
||||
echo " systemctl --user daemon-reload && systemctl --user enable --now week-review.timer"
|
||||
echo "timers (one machine only):"
|
||||
echo " systemctl --user daemon-reload"
|
||||
echo " systemctl --user enable --now week-review.timer hourlog.timer"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: hourlog
|
||||
description: Work out which client project each morning and afternoon went to, by reading Claude Code and Codex session activity, then reconcile that against the company timesheet and confirm the week's hours. Use when the user asks to log hours, fill in their timesheet, check what they worked on last week, or runs the Friday hour-logging session.
|
||||
user-invocable: true
|
||||
argument-hint: "[--week last|this | --since YYYY-MM-DD [--until YYYY-MM-DD]]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
- Edit
|
||||
- Write
|
||||
---
|
||||
|
||||
# Hour log
|
||||
|
||||
Turn session activity into a half-day-per-project proposal, check it against
|
||||
the timesheet, and submit only what the user approves.
|
||||
|
||||
The timesheet is a company record. Nothing is written to it without the user
|
||||
saying go, in this session, after seeing the table. An unattended run stops at
|
||||
the proposal.
|
||||
|
||||
## Setup (once per machine)
|
||||
|
||||
Three things must exist. Check them before anything else and stop with the
|
||||
missing step if not.
|
||||
|
||||
1. `~/.config/hourlog/projects.json` — path prefix to project mapping. Copy
|
||||
`<skill-dir>/config.example.json` and fill it in. **Never commit a filled
|
||||
config, and never put a project, client, or host name in this repo** — it
|
||||
is public.
|
||||
2. `HOURLOG_API` and `HOURLOG_TOKEN` in `~/.env.claude`. The token is a
|
||||
personal access token from the timesheet app's profile page. Scopes:
|
||||
`profile:read`, `schedule:read`, and `schedule:write` only if submitting.
|
||||
Never echo the token.
|
||||
3. Project names in the config must match the app exactly. Verify with
|
||||
`me-api.py projects` and fix the config, not the app.
|
||||
|
||||
## 1. Scan the sessions
|
||||
|
||||
```sh
|
||||
python3 <skill-dir>/scripts/scan-activity.py --week last
|
||||
```
|
||||
|
||||
Reports, per half-day, how many minutes each project was active and over which
|
||||
hours. The unit is a 5-minute slot in which at least one message was written,
|
||||
deduplicated per project — so a 40-subagent swarm on one project counts once,
|
||||
and two projects worked in parallel each keep their own slots. Message counts
|
||||
would let one overnight autonomous run outweigh a real morning.
|
||||
|
||||
Two things in the output need judgement, not arithmetic:
|
||||
|
||||
- **`[outside HHh]` flags.** Activity at 02:00 is usually an unattended run,
|
||||
not work. Discount it unless the user says otherwise.
|
||||
- **`unmapped:` lines.** A path with no rule. Either it is a new client
|
||||
directory the config is missing — say so and ask — or it is personal work
|
||||
that belongs in `exclude`. Never guess it into a client project.
|
||||
|
||||
## 2. Propose the hours
|
||||
|
||||
Half-days, not minutes. The user's own framing: a morning on one project is
|
||||
just four hours on that project.
|
||||
|
||||
- One project at 70% or more of the half-day's active minutes: all 4h to it.
|
||||
- Otherwise split 4h between the top two, in 15-minute steps, rounded to
|
||||
whatever matches the share.
|
||||
- Under 30 minutes of activity in a half-day: propose nothing and say the day
|
||||
looks empty. Do not invent a full day from a single message.
|
||||
|
||||
Session activity is evidence of what was worked on, not proof of hours. Meetings,
|
||||
review, and thinking leave no transcript. Say this when the proposal is thin,
|
||||
and let the user correct upward.
|
||||
|
||||
## 3. Reconcile against the timesheet
|
||||
|
||||
```sh
|
||||
python3 <skill-dir>/scripts/me-api.py schedule --start YYYY-MM-DD --end YYYY-MM-DD
|
||||
```
|
||||
|
||||
Each day comes back either already planned (an allocation with an entry id and
|
||||
planned hours) or absent. That splits the proposal in two:
|
||||
|
||||
- **Planned and matching** — confirm at the planned hours.
|
||||
- **Planned but the sessions disagree** — confirm at the observed hours, and
|
||||
show both numbers in the table so the user sees what changed.
|
||||
- **Not planned at all** — needs a new entry, which is a bigger claim. Flag it
|
||||
separately rather than folding it in.
|
||||
|
||||
## 4. Show the table, then ask
|
||||
|
||||
One row per half-day: date, weekday, half, project, proposed hours, what the
|
||||
timesheet currently says, and the action (confirm / adjust / add / skip).
|
||||
Flag every row where the evidence was weak or outside working hours.
|
||||
|
||||
Then ask once, plainly, whether to submit. Wait for an answer. Silence, a
|
||||
timeout, or "user may be away" is not approval — leave the timesheet alone and
|
||||
say the run is waiting.
|
||||
|
||||
## 5. Submit what was approved
|
||||
|
||||
```sh
|
||||
# confirm a planned day
|
||||
python3 <skill-dir>/scripts/me-api.py confirm --entry ID --hours 4 --dry-run
|
||||
# log a day with no allocation
|
||||
python3 <skill-dir>/scripts/me-api.py log --project ID --dates D,D --hours 4 --dry-run
|
||||
```
|
||||
|
||||
Run every write with `--dry-run` first and show the requests. Drop the flag
|
||||
only for the rows the user approved — not the whole table, if they approved
|
||||
part of it.
|
||||
|
||||
A `423` means the period is locked and ops has to reopen it. Report it and
|
||||
move on; it is not a failure to retry.
|
||||
|
||||
Re-read the schedule afterwards and confirm what landed. Report the diff, not
|
||||
an assumption.
|
||||
|
||||
## Scope
|
||||
|
||||
Which project a half-day went to, and confirming hours already worked. Not
|
||||
future allocations, not time off, not anyone else's schedule.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Copy to ~/.config/hourlog/projects.json and fill in real values there.",
|
||||
"This repo is public: no client names, no project names, no hostnames.",
|
||||
"'name' must match the project name in the timesheet app exactly — check",
|
||||
"it with `scripts/me-api.py projects`. 'match' is a list of path prefixes;",
|
||||
"the longest matching prefix wins, so a worktree can override its parent."
|
||||
],
|
||||
"timezone": "Europe/Lisbon",
|
||||
"afternoon_start_hour": 13,
|
||||
"slot_minutes": 5,
|
||||
"day_start_hour": 8,
|
||||
"day_end_hour": 20,
|
||||
"projects": [
|
||||
{
|
||||
"name": "<project name exactly as the timesheet app shows it>",
|
||||
"match": ["~/<client-dir>", "~/<client-dir>-worktrees"]
|
||||
},
|
||||
{
|
||||
"name": "<another project>",
|
||||
"match": ["~/<org>/<repo>", "~/<org>/<repo>-worktrees"]
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
"~/<personal-code-root>",
|
||||
"~/.config/agent-of-empires"
|
||||
]
|
||||
}
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin client for the company timesheet REST API.
|
||||
|
||||
Auth is a personal access token in $HOURLOG_TOKEN (scopes: profile:read,
|
||||
schedule:read, and schedule:write only if you intend to submit). The base URL
|
||||
is $HOURLOG_API. Both live in ~/.env.claude — never pass a token on argv, it
|
||||
lands in shell history and in the process table.
|
||||
|
||||
Commands:
|
||||
whoami GET /auth/me
|
||||
projects GET /my/projects
|
||||
categories GET /investment-categories
|
||||
schedule --start D --end D GET /my-schedule
|
||||
overdue GET /my-schedule/overdue
|
||||
confirm --entry ID --hours H PUT /day-entries/ID
|
||||
log --project ID --dates D,D --hours H POST /my/day-entries
|
||||
log --category ID --dates D,D --hours H POST /my/day-entries
|
||||
raw METHOD PATH [JSON] escape hatch
|
||||
|
||||
Every write takes --dry-run, which prints the exact request and sends nothing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
def request(method, path, body=None, dry=False):
|
||||
api = os.environ.get("HOURLOG_API")
|
||||
if not api:
|
||||
sys.exit("HOURLOG_API is not set — add the API base URL to "
|
||||
"~/.env.claude (it ends in /api)")
|
||||
url = api.rstrip("/") + path
|
||||
payload = json.dumps(body).encode() if body is not None else None
|
||||
if dry:
|
||||
print(f"DRY RUN {method} {url}")
|
||||
if body is not None:
|
||||
print(json.dumps(body, indent=1))
|
||||
return None
|
||||
|
||||
token = os.environ.get("HOURLOG_TOKEN")
|
||||
if not token:
|
||||
sys.exit("HOURLOG_TOKEN is not set — add it to ~/.env.claude "
|
||||
"(create one under profile > personal access tokens)")
|
||||
req = urllib.request.Request(url, data=payload, method=method, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")[:400]
|
||||
if e.code == 401:
|
||||
sys.exit("401 — token missing, expired, or lacking the scope")
|
||||
if e.code == 423:
|
||||
sys.exit("423 — that period is locked; ops has to reopen it")
|
||||
sys.exit(f"{e.code} {method} {path}: {detail}")
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"cannot reach the API: {e.reason}")
|
||||
|
||||
|
||||
def show(obj):
|
||||
json.dump(obj, sys.stdout, indent=1)
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("whoami")
|
||||
sub.add_parser("projects")
|
||||
sub.add_parser("categories")
|
||||
sub.add_parser("overdue")
|
||||
|
||||
s = sub.add_parser("schedule")
|
||||
s.add_argument("--start", required=True)
|
||||
s.add_argument("--end", required=True)
|
||||
|
||||
c = sub.add_parser("confirm")
|
||||
c.add_argument("--entry", required=True, help="day entry id")
|
||||
c.add_argument("--hours", required=True, help="decimal hours, e.g. 4 or 7.5")
|
||||
c.add_argument("--dry-run", action="store_true")
|
||||
|
||||
l = sub.add_parser("log")
|
||||
g = l.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--project", help="project id")
|
||||
g.add_argument("--category", help="investment category id")
|
||||
l.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD")
|
||||
l.add_argument("--hours", required=True, help="decimal hours per day")
|
||||
l.add_argument("--dry-run", action="store_true")
|
||||
|
||||
r = sub.add_parser("raw")
|
||||
r.add_argument("method")
|
||||
r.add_argument("path")
|
||||
r.add_argument("body", nargs="?")
|
||||
r.add_argument("--dry-run", action="store_true")
|
||||
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.cmd == "whoami":
|
||||
show(request("GET", "/auth/me"))
|
||||
elif a.cmd == "projects":
|
||||
show(request("GET", "/my/projects"))
|
||||
elif a.cmd == "categories":
|
||||
show(request("GET", "/investment-categories"))
|
||||
elif a.cmd == "overdue":
|
||||
show(request("GET", "/my-schedule/overdue"))
|
||||
elif a.cmd == "schedule":
|
||||
q = urllib.parse.urlencode({"start_date": a.start, "end_date": a.end})
|
||||
show(request("GET", f"/my-schedule?{q}"))
|
||||
elif a.cmd == "confirm":
|
||||
body = {"day_entry": {"actual_hours": a.hours}}
|
||||
out = request("PUT", f"/day-entries/{a.entry}", body, a.dry_run)
|
||||
if out is not None:
|
||||
show(out)
|
||||
elif a.cmd == "log":
|
||||
entry = {"dates": [d.strip() for d in a.dates.split(",") if d.strip()],
|
||||
"hours": a.hours}
|
||||
if a.project:
|
||||
entry["project_id"] = a.project
|
||||
else:
|
||||
entry["investment_category_id"] = a.category
|
||||
out = request("POST", "/my/day-entries", {"day_entry": entry}, a.dry_run)
|
||||
if out is not None:
|
||||
show(out)
|
||||
elif a.cmd == "raw":
|
||||
body = json.loads(a.body) if a.body else None
|
||||
out = request(a.method.upper(), a.path, body, a.dry_run)
|
||||
if out is not None:
|
||||
show(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bucket Claude Code and Codex session activity into half-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, half, 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.
|
||||
"""
|
||||
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",
|
||||
"afternoon_start_hour": 13,
|
||||
"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-2-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 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
|
||||
noon = cfg["afternoon_start_hour"]
|
||||
|
||||
# (date, half) -> project -> set of slot indices
|
||||
grid = collections.defaultdict(lambda: collections.defaultdict(set))
|
||||
hours = collections.defaultdict(lambda: collections.defaultdict(set))
|
||||
seen_cwds = collections.Counter()
|
||||
|
||||
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
|
||||
nfiles += 1
|
||||
seen_cwds[cwd or "?"] += 1
|
||||
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
|
||||
key = (t.date().isoformat(), "AM" if t.hour < noon else "PM")
|
||||
grid[key][proj].add(int(t.timestamp()) // slot)
|
||||
hours[key][proj].add(t.hour)
|
||||
del stamps
|
||||
|
||||
mins = cfg["slot_minutes"]
|
||||
report = []
|
||||
d = since
|
||||
while d <= until:
|
||||
for half in ("AM", "PM"):
|
||||
key = (d.isoformat(), half)
|
||||
per = grid.get(key, {})
|
||||
total = sum(len(v) for v in per.values())
|
||||
entry = {
|
||||
"date": d.isoformat(),
|
||||
"weekday": d.strftime("%a"),
|
||||
"half": half,
|
||||
"total_active_minutes": total * mins,
|
||||
"projects": [],
|
||||
}
|
||||
for proj, slots in sorted(per.items(), key=lambda kv: -len(kv[1])):
|
||||
hs = sorted(hours[key][proj])
|
||||
entry["projects"].append({
|
||||
"project": proj,
|
||||
"active_minutes": len(slots) * mins,
|
||||
"share": round(len(slots) / total, 3) if total else 0,
|
||||
"hours": hs,
|
||||
"outside_workday": [
|
||||
h for h in hs
|
||||
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']})\n")
|
||||
for e in report:
|
||||
if not e["projects"]:
|
||||
continue
|
||||
print(f"{e['date']} {e['weekday']} {e['half']} "
|
||||
f"{e['total_active_minutes']:>4}min active")
|
||||
for p in e["projects"]:
|
||||
flag = ""
|
||||
if p["outside_workday"]:
|
||||
flag = " [outside " + ",".join(
|
||||
f"{h:02d}h" for h in p["outside_workday"]) + "]"
|
||||
span = f"{p['hours'][0]:02d}-{p['hours'][-1]:02d}h" if p["hours"] else ""
|
||||
print(f" {p['share']*100:5.1f}% {p['active_minutes']:>4}min "
|
||||
f"{span:<8} {p['project']}{flag}")
|
||||
print()
|
||||
if unmapped:
|
||||
print("unmapped paths — add them to the config or the exclude list:")
|
||||
for u in unmapped:
|
||||
print(" ", u)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Start the Friday hour log in a tmux session
|
||||
Documentation=https://git.naps.pt/yolo/agent-skills
|
||||
ConditionPathIsDirectory=%h/tea/yolo/agent-skills
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=%h/tea/yolo/agent-skills/bin/hourlog-session.sh
|
||||
# When no tmux server is running yet this unit starts one; the default
|
||||
# control-group kill would take it back down as soon as ExecStart returns.
|
||||
KillMode=process
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Friday hour log, 16:00 Europe/Lisbon
|
||||
|
||||
[Timer]
|
||||
# System clock is UTC; the zone suffix keeps this at 16:00 wall time year-round.
|
||||
OnCalendar=Fri 16:00 Europe/Lisbon
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user