Timesheet¶
Bucket: Productivity ·
Slash command: /zsl:timesheet ·
Source: skills/productivity/timesheet/SKILL.md
When this skill activates
Claude Code matches this skill against the trigger text below. You can also invoke it explicitly with the slash command.
Summarize recent Claude Code sessions, Granola meetings, calendar events and sent email into timesheet bullets grouped by customer, then propose and log the matching Harvest time entries. Use when the user asks "what did I do today", wants a timesheet entry, daily standup notes, a summary of recent Claude Code sessions, or to log their day in Harvest.
Build a copy/paste-ready Markdown summary of work over a recent window (default 12 hours, or one calendar day with --day) — Claude Code sessions grouped by customer, the meetings, calendar events and sent email that fell inside the window, a short narrative of the day — and, where Harvest is connected, the time entries that record it. The script extracts raw session data; Claude synthesizes the bullets by reading the bash commands, user prompts, and files touched directly — no command parsing lives in the script.
Resolve the script (deterministic gate)¶
The digest script renders the deterministic parts — each project's duration label (Xh/X.Yh/Xm), the window header, the customer roll-up, and each project's activity blocks — so you copy them verbatim instead of re-deriving arithmetic or timezone conversion. Resolve its path once; this search covers the installed plugin, a personal skill, the remote ~/.claude/skills symlink, and this repo's own checkout:
DIGEST=$({ ls "$PWD"/skills/*/timesheet/scripts/digest_sessions.py 2>/dev/null
ls "$HOME/.claude/skills/timesheet/scripts/digest_sessions.py" 2>/dev/null
ls -d "$HOME"/.claude/plugins/cache/zsl-superpowers/zsl/*/skills/*/timesheet/scripts/digest_sessions.py 2>/dev/null | sort -Vr; } | head -1)
[ -n "$DIGEST" ] && echo "resolved: $DIGEST" || echo "zsl-gate: digest_sessions.py unresolved — see Fallback"
Use python3 "$DIGEST" … for every invocation below.
Fallback (if $DIGEST is empty): find the script under your install (skills/productivity/timesheet/scripts/digest_sessions.py in the repo) and invoke it by absolute path. If you cannot run it at all, compute the renders by hand:
duration_labelfrom integeractive_minutes:< 60→"{minutes}m"; elseh = minutes/60→"{int}h"when whole, else"{h:.1f}h"(270 →4.5h, 90 →1.5h, 180 →3h, 45 →45m).window_header:window_start/window_endin local time as"_{start:%Y-%m-%d %H:%M} → {end:%H:%M} {TZ}_".window_phrase:"last {n} hour{s}", singular only whenn == 1.blocks: sort a project's active 5-minute buckets and split wherever two neighbours are more than 30 minutes apart or the customer changes; each run is one block from its first bucket's start to its last bucket's end. A bucket's customer is the project's until a user prompt mentions another customer'spromptskeyword, which holds until the next keyword or a gap.
Required workflow¶
Include everything. Every project, meeting and calendar event in the window is in the render and in the Harvest table; nothing is folded into a neighbour for being small, deferred for being unclear, or dropped for lacking a home. The user trims afterwards if they want to.
-
List candidates.
python3 "$DIGEST" --list [--day YYYY-MM-DD | --hours N]shows projects with active hours, full path and customer. A request that names a day ("what did I do on the 16th", "Sept 16") is--day, never a trailing--hours 24, which would span midday to midday. Print it and carry on; exclusions happen only when the user asks for them. -
Resolve the customers file (see step 3's note) so every project has a customer before anything renders.
-
Extract sessions.
python3 "$DIGEST" [--day YYYY-MM-DD | --hours N] [--exclude PATTERN] [--only PATTERN] [--merge-nested]prints JSON. Top level:window_start/window_end(ISO, UTC),window_header,window_phrase,customers[](name,duration_label, already ordered) andcustomer_config(the user's map, echoed). Per project:customer,duration_label,blocks[](localstart/end/minutes/customer) andsessions[], each withuser_prompts,files_touched,bash_commands(deduped). Copy everyduration_label,window_header,window_phraseand block boundary verbatim.
customers is null when ~/.claude/timesheet-customers.json does not exist. Then, once, propose the file from the day's projects (one entry per customer with paths, domains, titles, harvest; the user's own company as self; their calendar id), write it on their yes, and re-run. A project whose customer is null gets one question — which customer? — and an added paths pattern. Never keep the map in memory alone: the file is what makes the grouping repeatable.
Every connector below is optional and its tools are usually deferred: before the first call, load all the ones present in one ToolSearch (select:mcp__claude_ai_Granola__list_meetings,mcp__claude_ai_Granola__get_meetings,mcp__claude_ai_Google_Calendar__list_events,mcp__claude_ai_Gmail__search_threads,mcp__harvest__list_projects,mcp__harvest__list_project_assignments,mcp__harvest__list_time_entries,mcp__harvest__log_time,mcp__harvest__get_account_settings); a name that does not resolve means that connector is absent.
-
Fetch meetings (Granola). If the Granola MCP tools exist (
mcp__claude_ai_Granola__list_meetings,mcp__claude_ai_Granola__get_meetings), calllist_meetingswithtime_range: "custom",custom_start/custom_endset to the JSON'swindow_start/window_endverbatim, andinvolvement{captured_by_me: true, listed_as_participant: true}. Thenget_meetingson the returned ids (ten per call) for the summaries. -
Fetch the calendar. If the Google Calendar MCP tools exist (
mcp__claude_ai_Google_Calendar__list_events), call it withcalendarId=customer_config.calendar(primary when unset),startTime/endTime= the same window,orderBy: "startTime". Calendar events are the source of truth for when and how long: they give Granola meetings their end time, add meetings Granola never saw (in-person, uncaptured), and mark context. Drop events the user declined (selfattendee withresponseStatus: declined); all-day and free (transparency: transparent) events are context for the narrative, never time entries. -
Fetch sent mail. If the Gmail MCP tools exist (
mcp__claude_ai_Gmail__search_threads), queryin:sent after:<start date> before:<day after end date>— Gmail's date filters are whole days in the account's timezone — withview: "THREAD_VIEW_MINIMAL", then keep only messages whoselabelIdsincludeSENTand whosedatefalls insidewindow_start/window_end. A sent email is a timestamped fact about who the user was working for at that minute; its subject and recipients are all the timesheet needs. Never fetch or quote bodies.
No Granola, Calendar or Gmail tools → skip that step silently; never ask the user to connect them.
-
Synthesize the timesheet. Write the outcome bullets and the "How the day went" narrative per the rules below. With Harvest connected they are the raw material for the rows' notes and are not printed on their own; without Harvest they are the deliverable, printed with the standard header.
-
Propose Harvest entries. If the Harvest MCP tools exist (
mcp__harvest__list_projects,mcp__harvest__list_project_assignments,mcp__harvest__list_time_entries,mcp__harvest__log_time), build the entries per the Harvest rules below, checklist_time_entriesfor the window's dates so nothing already logged is proposed twice, and print: one sentence saying what the table covers (the date, the sources read, whether anything is already logged), the entries table, the summary table, the unaccounted line, any questions, and "Log these N entries to Harvest?". No timesheet render, no narrative, no preamble beyond that sentence. Never calllog_timebefore the user has said yes to the table; on yes, log each row withspent_at,started_time/ended_time,project_id,task_idandnotes, then confirm the ids. No Harvest tools → end with "Copy this to your clipboard?" and on yesprintf '%s' "<bullets>" | pbcopy(macOS) /wl-copy/xclip -selection clipboard/clip(Windows).
When the request names projects ("timesheet for spark-asset-iq, last 4 hours"), pass them as --only and skip the picker.
Synthesis rules¶
Apply in order:
- One bullet per delivered outcome. Find git commits in
bash_commands(git commit -m,-am, heredoc forms — read natively, no regex). The commit subject is the bullet text. - PR opens count too.
gh pr create --title "..."→Opened PR: <title>. - Drop redundant signals.
gh pr mergeis implied by the prior open;git pushis plumbing. Neither gets a bullet. - Uncommitted work still renders. A project with no commit or PR keeps its heading and gets one bullet per thread saying what was built, decided or investigated, suffixed
(uncommitted). Its time is real and its Harvest row needs notes to carry. - Collapse WIP sequences. Commits that all advance one outcome ("wip", "fix typo", "Add foo") become one bullet with the outcome subject.
- Dedupe within a project. Identical subjects appear once.
- Tense. Keep the commit messages' imperative ("Add X", "Remove Y").
- Sent email is evidence. Under each customer, after Calendar, a
### Email · <count>section with one line per sent message in time order:HH:MM **<subject>** → <recipient organisations>. Attribute by the recipients' email domains againstdomains, elseself. Subjects and organisations only, never bodies or snippets. - Calendar entries are outcomes. One bullet per meeting or calendar event, in start order:
**<title>** — <counterparties by organisation> · <the decision or next step> · [notes](https://notes.granola.ai/d/<meeting id>). The link is the Granola meeting's id fromlist_meetings; it opens for the user and anyone the note is shared with, and is omitted for calendar-only meetings. A Granola summary supplies the outcome; a calendar-only meeting gets its attendees' organisations and, failing anything better, its title. Summaries only — never quote transcripts, credentials, or personal contact details. A window with meetings but no commits still renders. - Group by customer. Every repo and meeting sits under a
## <Customer>heading. Repos carrycustomerfrom the JSON; a meeting belongs to the customer whosedomainsmatch its participants' email domains or whosetitlesmatch its title, else toself. Customer order is the JSON'scustomers[]order: unassigned first, then by active time, the user's own company last. - Close with "How the day went". After the outcome sections, a
## How the day wentsection: three to six bullets in clock order, each opening with a bold time range and thread name, telling what was investigated, decided, or built — including work that produced no commit, which is exactly what the outcome bullets drop. Two sentences per bullet at most.
Output format when Harvest is not connected: title line # Timesheet — <window_phrase>, second line window_header, then one ## <Customer> block per customer holding its repos (### <name> · <duration_label>, active time descending) its ### Calendar · <count> and its ### Email · <count> (each omitted when none), then a single ## How the day went for the whole window. window_phrase, window_header and every duration_label are copied verbatim, never recomputed:
# Timesheet — last 12 hours
_2026-05-09 12:00 → 00:00 NZST_
## Spark
### spark-asset-iq · 4.5h
- Migrate Cognito user/identity pools to ap-southeast-6
- Polish READMEs with cross-references and updated seed-data layout
### Calendar · 1
- **AssetIQ platform review** — Cloudflare, Spark · Cloudflare to send a platform blueprint; next step is a working AI demo for Spark leadership · [notes](https://notes.granola.ai/d/08bd16bb-f53c-4996-80d8-624a5cc9e260)
## ZunoSmart Labs
### zsl-superpowers · 3h
- Add timesheet skill for Claude Code session summaries
## How the day went
- **09:00 to 12:30, Cognito move.** Migrated both pools to the new region and re-pointed the SPA. The identity pool needed its trust policy rewritten by hand.
- **14:00, platform review.** Demoed the estate page to Cloudflare; Workers and Durable Objects flagged as a fit for the API layer.
Harvest rules¶
- One row per project block, one row per meeting, no exceptions. A project's
blocks[]become rows on the block'scustomer(not the project's — a block re-attributed by apromptskeyword bills the other customer) using that customer'sharvest.projectandharvest.task; each accepted meeting becomes a row on its customer's project, usingharvest.meetings_taskwhen set. There is no minimum: a ten-minute block is a ten-minute row. Round block edges to the enclosing five minutes; never merge across a meeting or across customers. - Same customer, same minutes, one row. When a meeting and a block belong to the same customer and overlap in time, they merge into one row spanning both, with both sets of notes; a huddle held while working in the repo is one piece of time, not two. Overlaps across different customers stay separate rows and are flagged.
- Email lands in the row that contains it, or makes one. A sent email whose time falls inside a row on the same customer adds a line
HH:MM email: <subject> → <organisation>to that row's notes. An email outside every row becomes its own ten-minute row ending at the send time, rounded to the enclosing five minutes, on the recipient customer's project and task; emails to the same customer within 30 minutes of each other share one row from the first to the last. - A missing home is a suggestion, not a dropped row. When a customer has no
harvestmapping, orlist_projects/list_project_assignmentscannot find the mapped project or task, keep the row in the table with the project column readingneeds project, and under the table suggest exactly what to add in Harvest — client, project name, task — offering to create it withcreate_project/add_task_to_projecton a yes, then write the mapping into the customers file. Unknown does not mean unbilled. - Ambiguity is a question in the table, not a hold. Two calendar events that overlap, or a meeting whose customer is unclear, still get their rows; the notes column names the question and the user answers it before the yes.
- Calendar wins on duration. A meeting's row spans the calendar event. With no calendar event, one hour from the Granola start, flagged as assumed.
- Notes carry the timesheet. A row's notes are that customer's bullets for the block or the meeting's bullet, followed by the matching "How the day went" lines, and for a Granola meeting the plain
https://notes.granola.ai/d/<meeting id>URL on its own last line so Harvest renders it as a link. No transcripts, no credentials. - Questions stay in the table. The proposal's notes column may carry a question or an overlap flag; the notes written to Harvest never do. Once the user says yes, log the facts only, and a row whose question was answered "no" is dropped, not logged with the doubt attached.
- Resolve ids, don't guess.
list_projects(active) for the project id,list_project_assignmentswithassignment_type: "tasks"for the task id;get_account_settingstells you whether the account takesstarted_time/ended_time(wants_timestamp_timers) or onlyhours, and whether notes are required. - Show gaps, don't fill them. Time with no session, meeting or calendar evidence is listed under the table as unaccounted, for the user to fill.
- Table columns:
#, project, task, time, hours, notes carry. Under it a summary table: one line per Harvest project with customer, rows, hours and billable yes/no (from the project'sis_billable), then a total line with hours, billable hours and non-billable hours, then one line naming the unaccounted gaps.
Common flags¶
--day YYYY-MM-DD— one local calendar day, midnight to midnight; overrides--hours. The title line becomes# Timesheet — 16 September 2026.--hours N— trailing window back from now, decimals OK. Default 12.--list— project picker (basename, active hours, session count, full path, customer) instead of JSON.--only PATTERN/--exclude PATTERN— bare patterns match basename (case-insensitive substring); patterns containing/match the full cwd. Repeatable;--excludeis ignored when--onlyis set.--merge-nested— fold projects nested under another project's cwd into the parent. Monorepos only: a repo cloned inside a plain customers folder would be renamed after the folder.--include-noise— keep ClaudeProbe / CodexBar health-check sessions. Implicit with--only.--customers PATH— customer map JSON; default~/.claude/timesheet-customers.json.{"self": "<my company>", "calendar": "<calendar id>", "customers": {"<name>": {"paths": [...], "prompts": [...], "domains": [...], "titles": [...], "harvest": {"project": "<name>", "task": "<name>", "meetings_task": "<name>"}}}};promptsare case-insensitive substrings of a user prompt that move the following minutes to that customer inside another customer's repo;pathsuse the--onlymatching rules, first matching entry wins, so list specific repos before an org-wide catch-all, and write them with a/(zunosmartlabs/spark-asset-iq) so the repo's worktrees and.scratchsessions inherit the customer.--projects-dir PATH— alternative to~/.claude/projects(rare).