SLOPSHOPPER

worklog

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

newpanespinnercommandstatusprompt
v0.1.0MITupdated 2026-10-06santtisosa/worklog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worklog
│ ┃ Worklog · this week ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing logged this week yet. │ ┃ Total this week: 0m · /worklog export for a ⏺ Read(src/auth.ts) │ ┃ CSV ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /worklog │ ⎿ worklog: Worklog opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⏱ - 0m

Draws

Pane · Worklog · this week
Nothing logged this week yet. Total this week: 0m · /worklog export for a CSV
Prompt hint
⟨Claude Code's own drawing⟩ ⏱ - 0m
README

worklog

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
  • Active time is every reply Claude gives plus your pauses between a reply and your next prompt, up to the idle limit (5 minutes by default). Longer pauses do not count.
  • A branch that names a ticket (feature/ABC-123-login) is grouped under the ticket; otherwise under the branch.
  • Time from every Claude Code window on the same repo and branch adds up.
  • /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.

Install

Requires Claude Code 2.1.287 or later.

/plugin marketplace add santtisosa/worklog
/plugin install worklog@worklog

Settings (/config)

SettingDefault
Placementhinthint: at the end of the hint line under the prompt. status: a pinned notice under the prompt.
Idle after (minutes)5Longer pauses between a reply and your next prompt are not counted.

Develop

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.

License

MIT

Source 3 files
hooks/register.tsx 175 lines
1import { 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}
175
hooks/worklog.ts 64 lines
1import 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')
64
types/index.d.ts 12 lines
1export 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