Time you actively work with Claude, per repo and branch (or ticket), with a weekly view and a CSV timesheet.

A Claude Code mod that tracks the time you actively work with Claude, per repo and branch (or ticket), and turns it into a weekly view and a CSV timesheet.
⏱ ABC-123 1h 24m
feature/ABC-123-login) is grouped under the ticket; otherwise under the branch./worklog opens this week: day, repo, branch or ticket and time, with the total./worklog export writes everything to ~/.claude/worklog/worklog.csv.Everything stays on your machine: each session writes its own file in ~/.claude/worklog/, so parallel sessions never overwrite each other.
Requires Claude Code 2.1.287 or later.
/plugin marketplace add santtisosa/worklog
/plugin install worklog@worklog
/config)| Setting | Default | |
|---|---|---|
| Placement | hint | hint: at the end of the hint line under the prompt. status: a pinned notice under the prompt. |
| Idle after (minutes) | 5 | Longer pauses between a reply and your next prompt are not counted. |
claude plugin validate .
claude plugin test .
hooks/worklog.ts holds the pure logic (keys, merge, week, CSV); hooks/register.tsx wires it to Claude Code.
MIT
hooks/register.tsx 175 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entries, Row, Today } from '../types'
5import {
6 addTime, branchFromHead, dayOf, formatDuration, keyOf, labelOf, mergeEntries, toCsv, toRows, weekStartOf,
7} from './worklog'
8
9const PANE = 'worklog'
10const MINUTE = 60_000
11const LABEL_LENGTH = 32
12const DAY_COLUMN = 12
13const REPO_COLUMN = 20
14const LABEL_COLUMN = 36
15
16const entries = atom({ plugin: 'worklog', key: 'entries' } as const, {} as Entries)
17const today = atom({ plugin: 'worklog', key: 'today' } as const, null as Today | null)
18const week = atom({ plugin: 'worklog', key: 'week' } as const, [] as Row[])
19
20async function folderOf($: EngineInterface): Promise<string> {
21 return `${(await $.env.get('HOME')) ?? '.'}/.claude/worklog`
22}
23
24async function branchOf($: EngineInterface, root: string): Promise<string> {
25 const dotGit = `${root}/.git`
26
27 if(!(await $.fs.exists(dotGit))) {
28 return '-'
29 }
30
31 const isWorktree = (await $.fs.stat(dotGit)).kind === 'file'
32 const gitDir = isWorktree ? (await $.fs.read(dotGit)).replace(/^gitdir:\s*/, '').trim() : dotGit
33
34 return branchFromHead(await $.fs.read(`${gitDir}/HEAD`))
35}
36
37async function currentKey($: EngineInterface, at: number): Promise<{ key: string; label: string }> {
38 const root = await $.session.root()
39 const repo = (await $.session.repo())?.name ?? root.split('/').pop() ?? root
40 const label = labelOf(await branchOf($, root))
41
42 return { key: keyOf(dayOf(at), repo, label), label }
43}
44
45async function everySession($: EngineInterface): Promise<Entries> {
46 const folder = await folderOf($)
47
48 if(!(await $.fs.exists(folder))) {
49 return {}
50 }
51
52 const files = (await $.fs.list(folder)).filter(file => file.kind === 'file' && file.name.endsWith('.json'))
53 const all = await Promise.all(files.map(async file => JSON.parse(await $.fs.read(`${folder}/${file.name}`)) as Entries))
54
55 return mergeEntries(all)
56}
57
58async function showToday($: EngineInterface, isStatus: boolean) {
59 const at = await $.clock.now()
60 const { key, label } = await currentKey($, at)
61 const ms = (await everySession($))[key] ?? 0
62 const short = label.length > LABEL_LENGTH ? `${label.slice(0, LABEL_LENGTH - 1)}…` : label
63 await update($, today, () => ({ label: short, ms }))
64
65 if(isStatus) {
66 $.ui.status(`⏱ ${short} ${formatDuration(ms)}`)
67 }
68}
69
70async function addWork($: EngineInterface, ms: number, isStatus: boolean) {
71 if(ms <= 0) {
72 return
73 }
74
75 const { key } = await currentKey($, await $.clock.now())
76 const mine = await update($, entries, current => addTime(current, key, ms))
77 await $.fs.write(`${await folderOf($)}/${await $.session.id()}.json`, JSON.stringify(mine))
78 await showToday($, isStatus)
79}
80
81export const register: Register = (on, options) => {
82 const isStatus = String(options.placement ?? 'hint') === 'status'
83 const idleMs = Number(options.idleMinutes ?? 5) * MINUTE
84 // ponytail: kept in the module, so a reload forgets the last reply and skips one pause at most
85 let lastReplyAt: number | undefined
86
87 on('session.start', async ($, e, next) => {
88 const result = await next(e)
89 await $.command.register({ name: PANE, description: 'Time worked per repo and branch this week', argumentHint: '[export]' })
90 $.ui.status(undefined)
91 await showToday($, isStatus)
92
93 return result
94 })
95
96 on('prompt.submit', async ($, e, next) => {
97 const isNewTurn = e.turnId === undefined && lastReplyAt !== undefined
98
99 if(isNewTurn) {
100 const pause = (await $.clock.now()) - lastReplyAt!
101 await addWork($, pause <= idleMs ? pause : 0, isStatus)
102 }
103
104 return next(e)
105 }).catch(($, e, next) => next(e))
106
107 on('turn.complete', async ($, e, next) => {
108 if(e.agentId === undefined) {
109 await addWork($, e.durationMs, isStatus)
110 lastReplyAt = await $.clock.now()
111 }
112
113 return next(e)
114 }).catch(($, e, next) => next(e))
115
116 on('command.run', { command: PANE }, async ($, e) => {
117 const rows = toRows(await everySession($), weekStartOf(await $.clock.now()))
118
119 if(e.args.trim() === 'export') {
120 const path = `${await folderOf($)}/worklog.csv`
121 await $.fs.write(path, toCsv(toRows(await everySession($), '')))
122
123 return { text: `Timesheet written to ${path}` }
124 }
125
126 await update($, week, () => rows)
127 await $.ui.open({ id: PANE, title: 'Worklog · this week', focus: true, closeOnEscape: true })
128
129 return { text: 'Worklog opened.' }
130 })
131
132 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
133 const line = await next(e)
134 const current = await read($, today)
135
136 if(isStatus || current === null) {
137 return line
138 }
139
140 const { Box, Text } = $.ui.resolve(e)
141
142 return (
143 <Box gap={2}>
144 {line}
145 <Box gap={1}>
146 <Text color="magenta">⏱</Text>
147 <Text color="cyan">{current.label}</Text>
148 <Text color="magenta" bold>{formatDuration(current.ms)}</Text>
149 </Box>
150 </Box>
151 )
152 })
153
154 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155 const rows = await read($, week)
156 const { Box, Text } = $.ui.resolve(e)
157 const total = rows.reduce((sum, row) => sum + row.ms, 0)
158
159 return (
160 <Box flexDirection="column">
161 {rows.length === 0 ? <Text dimColor>Nothing logged this week yet.</Text> : null}
162 {rows.map(row => (
163 <Box key={`${row.day}|${row.repo}|${row.label}`}>
164 <Box width={DAY_COLUMN}><Text dimColor>{row.day}</Text></Box>
165 <Box width={REPO_COLUMN}><Text wrap="truncate-end">{row.repo}</Text></Box>
166 <Box width={LABEL_COLUMN}><Text wrap="truncate-end">{row.label}</Text></Box>
167 <Text bold>{formatDuration(row.ms)}</Text>
168 </Box>
169 ))}
170 <Text dimColor>{`Total this week: ${formatDuration(total)} · /worklog export for a CSV`}</Text>
171 </Box>
172 )
173 })
174}
175hooks/worklog.ts 64 lines1import type { Entries, Row } from '../types'
2
3const MINUTE = 60_000
4const DAY_MS = 24 * 60 * MINUTE
5const TICKET = /[A-Z][A-Z0-9]+-\d+/
6const SHORT_SHA = 7
7const SEPARATOR = '|'
8
9const pad = (value: number): string => String(value).padStart(2, '0')
10
11export const dayOf = (ms: number): string => {
12 const date = new Date(ms)
13
14 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
15}
16
17export const weekStartOf = (ms: number): string => {
18 const daysSinceMonday = (new Date(ms).getDay() + 6) % 7
19
20 return dayOf(ms - daysSinceMonday * DAY_MS)
21}
22
23export const branchFromHead = (head: string): string => {
24 const ref = /^ref:\s*refs\/heads\/(.+)$/m.exec(head)?.[1]?.trim()
25
26 return ref ?? head.trim().slice(0, SHORT_SHA)
27}
28
29export const labelOf = (branch: string): string => TICKET.exec(branch)?.[0] ?? branch
30
31export const keyOf = (day: string, repo: string, label: string): string => [day, repo, label].join(SEPARATOR)
32
33export const addTime = (entries: Entries, key: string, ms: number): Entries =>
34 ({ ...entries, [key]: (entries[key] ?? 0) + ms })
35
36export const mergeEntries = (all: readonly Entries[]): Entries =>
37 all.reduce<Entries>(
38 (merged, entries) => Object.entries(entries).reduce((sum, [key, ms]) => addTime(sum, key, ms), merged),
39 {})
40
41export const toRows = (entries: Entries, fromDay: string): Row[] =>
42 Object.entries(entries)
43 .map(([key, ms]) => {
44 const [day = '', repo = '', label = ''] = key.split(SEPARATOR)
45
46 return { day, repo, label, ms }
47 })
48 .filter(row => row.day >= fromDay)
49 .sort((a, b) => b.day.localeCompare(a.day) || b.ms - a.ms)
50
51export const formatDuration = (ms: number): string => {
52 const minutes = Math.round(ms / MINUTE)
53 const hours = Math.floor(minutes / 60)
54
55 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
56}
57
58const csvField = (value: string): string => `"${value.replaceAll('"', '""')}"`
59
60export const toCsv = (rows: readonly Row[]): string =>
61 ['date,repo,branch_or_ticket,minutes']
62 .concat(rows.map(row => [row.day, csvField(row.repo), csvField(row.label), Math.round(row.ms / MINUTE)].join(',')))
63 .join('\n')
64types/index.d.ts 12 lines1export type Entries = Record<string, number>
2
3export type Row = { day: string; repo: string; label: string; ms: number }
4
5export type Today = { label: string; ms: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 worklog: { entries: Entries; today: Today | null; week: Row[] }
10 }
11}
12