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:
naps62
2026-08-14 17:35:21 +00:00
parent a0b42c9210
commit e4ffd5d93d
10 changed files with 641 additions and 2 deletions
+1
View File
@@ -1,2 +1,3 @@
*.bak
.DS_Store
__pycache__/
+29
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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"
+122
View File
@@ -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.
+28
View File
@@ -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"
]
}
+139
View File
@@ -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())
+234
View File
@@ -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())
+12
View File
@@ -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
+11
View File
@@ -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