Active hours per repo with a /hours pane, CSV export and a monthly goal line; drafts a build-in-public post when you merge a PR

Active hours per repo, and a drafted post when you merge a pull request in a public repo.
Time counts as active in two ways: the length of each turn, and the gap between the end of one turn and your next prompt when that gap is shorter than idleMinutes. A longer gap is a break and counts for nothing. Time is keyed by the repo root (git rev-parse --show-toplevel, the session folder outside a repo) and the local day, and kept in the plugin's store, so it survives sessions.
/hours opens a pane with today, this week (from Monday) and this month per repo, and a total. The Copy CSV button (Export CSV when csvPath is set) writes repo,day,hours rows to csvPath, or copies them to the clipboard when no path is set.
With hourlyRate and monthlyGoal both above 0, the pane starts with a goal line: Oct: 42h ≈ 31.5k UAH of 60k.
When a gh pr merge Bash call succeeds, the pull request is read with gh pr view --json title,body,url,state. If it is merged, and the repo's owner/name or folder matches publicRepos, one $.model.complete call writes a short post (what shipped, why it matters, no hype). It lands in draftsDir as <timestamp>-<title>.md and a toast gives the path. For --auto merges, the pull request is looked at again when a turn ends. Each pull request is drafted once.
Nothing is posted anywhere. The draft is a file for you to edit and publish.
| Field | Default | Meaning |
|---|---|---|
idleMinutes | 10 | A gap under this counts as active |
csvPath | empty | File for the CSV export; empty copies to the clipboard |
hourlyRate | 0 | Money per hour for the goal line |
currency | USD | Label on the goal line |
monthlyGoal | 0 | Income goal per month; 0 turns the goal line off |
publicRepos | empty (off) | Regex over owner/name and the repo folder, e.g. ^my-org/ |
draftsDir | empty (off) | Folder for drafted posts |
draftModel | haiku | Model that writes the draft |
Drafting needs both publicRepos and draftsDir.
Hours are local. Each drafted post is one $.model.complete request (at most 400 output tokens, the pull request's title and the first 4000 characters of its description as input), plus a gh pr view call.
hooks/register.tsx 217 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Hours } from '../types'
5import {
6 accrue,
7 baseName,
8 dayKey,
9 fmtDuration,
10 goalLine,
11 mergeTarget,
12 postPrompt,
13 repoFromRemote,
14 slugify,
15 stamp,
16 summarize,
17 toCsv,
18} from './time'
19
20const PANE = 'hours'
21const hours = atom({ plugin: 'ledger', key: 'hours' } as const, {} as Hours)
22
23function compile(source: unknown): RegExp | undefined {
24 if (typeof source !== 'string' || source.trim() === '') return undefined
25 try {
26 return new RegExp(source, 'i')
27 } catch {
28 return undefined
29 }
30}
31
32// Read-modify-write against the store so two sessions in different repos do not overwrite each other.
33async function addActive($: EngineInterface, repo: string, ms: number, now: number) {
34 if (ms <= 0) return
35 const stored = ((await $.store.get('hours')) ?? {}) as Hours
36 const next = accrue(stored, repo, dayKey(now), ms)
37 await $.store.set('hours', next)
38 await update($, hours, () => next)
39}
40
41async function repoRoot($: EngineInterface, cwd: string): Promise<string> {
42 const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel']).catch(() => undefined)
43 const top = r?.exitCode === 0 ? r.stdout.trim() : ''
44 return top || cwd
45}
46
47async function exportCsv($: EngineInterface, path: string, surface: RenderSurface) {
48 const csv = toCsv(await read($, hours))
49 if (path) {
50 await $.fs.write(path, csv)
51 $.ui.toast(`ledger: wrote ${path}`)
52 return
53 }
54 const copied = await $.ui.copy({ text: csv, surface })
55 $.ui.toast(copied.isCopied ? 'ledger: CSV copied to the clipboard' : 'ledger: no clipboard here; set a CSV export path')
56}
57
58type PullRequest = { title: string; body: string; url: string; state: string }
59
60async function viewPr($: EngineInterface, root: string, selector: string | undefined, repo?: string): Promise<PullRequest | undefined> {
61 const argv = ['gh', 'pr', 'view', ...(selector ? [selector] : []), ...(repo ? ['-R', repo] : []), '--json', 'title,body,url,state']
62 const r = await $.process.run(argv, { cwd: root }).catch(() => undefined)
63 if (!r || r.exitCode !== 0) return undefined
64 try {
65 const pr = JSON.parse(r.stdout) as Partial<PullRequest>
66 return { title: pr.title ?? '', body: pr.body ?? '', url: pr.url ?? '', state: pr.state ?? '' }
67 } catch {
68 return undefined
69 }
70}
71
72// A merged pull request in a public repo becomes a markdown draft on disk. Nothing is posted anywhere.
73async function draftPost(
74 $: EngineInterface,
75 root: string,
76 pr: PullRequest,
77 publicRepos: RegExp,
78 dir: string,
79 model: string,
80): Promise<boolean> {
81 const remote = await $.process.run(['git', '-C', root, 'remote', 'get-url', 'origin']).catch(() => undefined)
82 const slug = (remote?.exitCode === 0 ? repoFromRemote(remote.stdout) : undefined) ?? baseName(root)
83 if (!publicRepos.test(slug) && !publicRepos.test(root)) return false
84
85 const done = (((await $.store.get('drafted')) as string[] | undefined) ?? []).slice(-200)
86 if (pr.url && done.includes(pr.url)) return false
87
88 const reply = await $.model.complete({ model, prompt: postPrompt(pr, slug), maxTokens: 400, timeoutMs: 60000 })
89 if (!reply.isAnswered) {
90 $.ui.toast(`ledger: no draft for ${pr.title} (${reply.reason})`)
91 return false
92 }
93 const now = await $.clock.now()
94 const path = `${dir.replace(/\/+$/, '')}/${stamp(now)}-${slugify(pr.title)}.md`
95 await $.fs.write(path, `# ${pr.title}\n\n${pr.url}\n\n---\n\n${reply.text.trim()}\n`)
96 await $.store.set('drafted', [...done, pr.url])
97 $.ui.toast(`ledger: post drafted at ${path}`)
98 return true
99}
100
101// gh pr merge --auto merges later; look again when a turn ends.
102async function checkPending(
103 $: EngineInterface,
104 root: string,
105 pending: { selector?: string; repo?: string }[],
106 publicRepos: RegExp,
107 dir: string,
108 model: string,
109) {
110 for (const item of pending.splice(0)) {
111 const pr = await viewPr($, root, item.selector, item.repo)
112 if (pr?.state === 'MERGED') await draftPost($, root, pr, publicRepos, dir, model).catch(() => false)
113 else if (pr?.state === 'OPEN') pending.push(item)
114 }
115}
116
117export const register: Register = (on, options) => {
118 const idleMs = Math.max(1, typeof options.idleMinutes === 'number' ? options.idleMinutes : 10) * 60000
119 const csvPath = typeof options.csvPath === 'string' ? options.csvPath.trim() : ''
120 const rate = typeof options.hourlyRate === 'number' ? options.hourlyRate : 0
121 const goal = typeof options.monthlyGoal === 'number' ? options.monthlyGoal : 0
122 const currency = typeof options.currency === 'string' && options.currency ? options.currency : 'USD'
123 const publicRepos = compile(options.publicRepos)
124 const draftsDir = typeof options.draftsDir === 'string' ? options.draftsDir.trim() : ''
125 const draftModel = typeof options.draftModel === 'string' && options.draftModel ? options.draftModel : 'haiku'
126 const isDrafting = publicRepos !== undefined && draftsDir !== ''
127
128 // Per-session bookkeeping; a reload starts it over, losing at most the open gap.
129 let repo = ''
130 let lastActivity = 0
131 let isRunning = false
132 const pending: { selector?: string; repo?: string }[] = []
133
134 on('session.start', async ($, e, next) => {
135 repo = await repoRoot($, e.cwd)
136 const stored = (await $.store.get('hours')) as Hours | undefined
137 await update($, hours, () => stored ?? {})
138 await $.command.register({ name: 'hours', description: 'Active hours per repo: today, this week, this month' })
139 return next(e)
140 })
141
142 // The gap since the last activity counts as work when it is shorter than the idle limit.
143 on('prompt.submit', async ($, e, next) => {
144 const now = await $.clock.now()
145 if (!e.isMeta && !isRunning) {
146 const gap = now - lastActivity
147 if (lastActivity > 0 && gap < idleMs && repo) await addActive($, repo, gap, now)
148 lastActivity = now
149 }
150 return next(e)
151 })
152
153 on('turn.start', async ($, e, next) => {
154 if (!e.agentId) isRunning = true
155 return next(e)
156 })
157
158 on('turn.complete', async ($, e, next) => {
159 if (!e.agentId) {
160 isRunning = false
161 const now = await $.clock.now()
162 if (repo) await addActive($, repo, e.durationMs, now)
163 lastActivity = now
164 if (isDrafting && publicRepos) await checkPending($, repo, pending, publicRepos, draftsDir, draftModel)
165 }
166 return next(e)
167 })
168
169 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
170 const target = isDrafting ? mergeTarget(e.command) : undefined
171 const ran = await next(e)
172 if (!target || ran.deny !== undefined || ran.isError) return ran
173 const root = repo
174 $.clock.after(0, () => {
175 void (async () => {
176 const pr = await viewPr($, root, target.selector, target.repo)
177 if (!pr || !publicRepos) return
178 if (pr.state === 'MERGED') await draftPost($, root, pr, publicRepos, draftsDir, draftModel)
179 else if (target.isAuto && pr.state === 'OPEN') pending.push({ selector: target.selector, repo: target.repo })
180 })().catch(() => undefined)
181 })
182 return ran
183 })
184
185 on('command.run', { command: 'hours' }, async $ => {
186 await $.ui.open({ id: PANE, title: 'Hours', focus: true, closeOnEscape: true })
187 return { text: 'Hours pane opened.' }
188 })
189
190 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191 const { Box, Text, Button } = $.ui.resolve(e)
192 const data = await read($, hours)
193 const now = await $.clock.now()
194 const { rows, total } = summarize(data, now)
195 const goalText = goalLine(total.month, now, rate, goal, currency)
196 const name = Math.max(8, Math.min(28, e.props.bodyColumns - 30))
197 const cell = (text: string) => text.padStart(9)
198 const line = (label: string, a: string, b: string, c: string) =>
199 `${label.length > name ? `${label.slice(0, name - 1)}…` : label.padEnd(name)}${cell(a)}${cell(b)}${cell(c)}`
200
201 return (
202 <Box flexDirection="column">
203 {goalText && <Text bold>{goalText}</Text>}
204 <Text dimColor>{line('repo', 'today', 'week', 'month')}</Text>
205 {rows.length === 0 && <Text dimColor>No active time recorded yet.</Text>}
206 {rows.map(r => (
207 <Text key={r.repo}>{line(baseName(r.repo), fmtDuration(r.today), fmtDuration(r.week), fmtDuration(r.month))}</Text>
208 ))}
209 {rows.length > 1 && <Text bold>{line('all', fmtDuration(total.today), fmtDuration(total.week), fmtDuration(total.month))}</Text>}
210 <Box marginTop={1}>
211 <Button key="export" label={csvPath ? 'Export CSV' : 'Copy CSV'} hotkey="e" onPress={press => exportCsv($, csvPath, press.surface)} />
212 </Box>
213 </Box>
214 )
215 })
216}
217hooks/time.ts 131 lines1import type { Hours } from '../types'
2
3const pad = (n: number) => String(n).padStart(2, '0')
4const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5
6// Local calendar day, as the sortable key the store uses.
7export function dayKey(ms: number): string {
8 const d = new Date(ms)
9 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
10}
11
12// Monday of the week that holds `ms`.
13export function weekStartKey(ms: number): string {
14 const d = new Date(ms)
15 const back = (d.getDay() + 6) % 7
16 return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime())
17}
18
19export const monthStartKey = (ms: number) => dayKey(ms).slice(0, 8) + '01'
20export const monthName = (ms: number) => MONTHS[new Date(ms).getMonth()] ?? ''
21
22// Adds active time to one repo and day, and drops days older than a year.
23export function accrue(hours: Hours, repo: string, day: string, ms: number): Hours {
24 if (ms <= 0) return hours
25 const days = { ...hours[repo], [day]: (hours[repo]?.[day] ?? 0) + ms }
26 const next: Hours = { ...hours, [repo]: days }
27 const cutoff = Number(day.slice(0, 4)) - 1 + day.slice(4)
28 for (const key of Object.keys(days)) if (key < cutoff) delete days[key]
29 return next
30}
31
32export type Row = { repo: string; today: number; week: number; month: number }
33
34export function summarize(hours: Hours, now: number): { rows: Row[]; total: Row } {
35 const today = dayKey(now)
36 const week = weekStartKey(now)
37 const month = monthStartKey(now)
38 const rows: Row[] = Object.entries(hours).map(([repo, days]) => {
39 const row: Row = { repo, today: 0, week: 0, month: 0 }
40 for (const [day, ms] of Object.entries(days)) {
41 if (day === today) row.today += ms
42 if (day >= week && day <= today) row.week += ms
43 if (day >= month && day <= today) row.month += ms
44 }
45 return row
46 })
47 const total: Row = { repo: 'all', today: 0, week: 0, month: 0 }
48 for (const r of rows) {
49 total.today += r.today
50 total.week += r.week
51 total.month += r.month
52 }
53 return { rows: rows.filter(r => r.month > 0 || r.week > 0 || r.today > 0).sort((a, b) => b.month - a.month), total }
54}
55
56export function fmtDuration(ms: number): string {
57 const minutes = Math.round(ms / 60000)
58 if (minutes < 60) return `${minutes}m`
59 return `${Math.floor(minutes / 60)}h ${pad(minutes % 60)}m`
60}
61
62export const hoursOf = (ms: number) => ms / 3600000
63
64function money(value: number): string {
65 if (value >= 1000) return `${(Math.round(value / 100) / 10).toString()}k`
66 return String(Math.round(value))
67}
68
69// "Oct: 42h ≈ 31.5k UAH of 60k"; undefined while the goal is off.
70export function goalLine(monthMs: number, now: number, rate: number, goal: number, currency: string): string | undefined {
71 if (!(rate > 0) || !(goal > 0)) return undefined
72 const h = hoursOf(monthMs)
73 return `${monthName(now)}: ${Math.round(h)}h ≈ ${money(h * rate)} ${currency} of ${money(goal)}`
74}
75
76export function toCsv(hours: Hours): string {
77 const lines = ['repo,day,hours']
78 for (const [repo, days] of Object.entries(hours).sort(([a], [b]) => a.localeCompare(b)))
79 for (const [day, ms] of Object.entries(days).sort(([a], [b]) => a.localeCompare(b)))
80 lines.push(`${csvCell(repo)},${day},${hoursOf(ms).toFixed(2)}`)
81 return lines.join('\n') + '\n'
82}
83
84const csvCell = (text: string) => (/[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text)
85
86export const baseName = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
87
88// A `gh pr merge` command and the pull request it names (a number, url or branch), if any.
89export function mergeTarget(command: string): { selector?: string; repo?: string; isAuto: boolean } | undefined {
90 const m = /(?:^|[;&|]\s*|&&\s*)gh\s+pr\s+merge\b([^;&|\n]*)/.exec(command)
91 if (!m) return undefined
92 const args = (m[1] ?? '').trim().split(/\s+/).filter(Boolean)
93 const withValue = new Set(['-R', '--repo', '-b', '--body', '-F', '--body-file', '-t', '--subject', '--match-head-commit', '-A', '--author-email'])
94 let selector: string | undefined
95 let repo: string | undefined
96 for (let i = 0; i < args.length; i += 1) {
97 const arg = args[i] ?? ''
98 if (arg === '-R' || arg === '--repo') repo = args[i + 1]
99 else if (arg.startsWith('--repo=')) repo = arg.slice('--repo='.length)
100 if (withValue.has(arg)) i += 1
101 else if (!arg.startsWith('-') && selector === undefined) selector = arg
102 }
103 return { selector, repo, isAuto: args.includes('--auto') }
104}
105
106export function repoFromRemote(remote: string): string | undefined {
107 const m = /[:/]([^/:]+\/[^/]+?)(?:\.git)?\s*$/.exec(remote.trim())
108 return m?.[1]
109}
110
111export function slugify(text: string): string {
112 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'post'
113}
114
115export function stamp(ms: number): string {
116 return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z').replace(/:/g, '-')
117}
118
119export function postPrompt(pr: { title: string; body: string; url: string }, repo: string): string {
120 return [
121 `Write a short post for a developer's build-in-public feed about a pull request that just merged in ${repo}.`,
122 'Say what shipped and why it matters to someone using the project. Plain words, first person, 60 to 110 words.',
123 'No hype, no emoji, no hashtags, no marketing phrases. Do not invent facts that are not in the pull request.',
124 'Reply with the post text only.',
125 '',
126 `Title: ${pr.title}`,
127 `URL: ${pr.url}`,
128 `Description:\n${pr.body.slice(0, 4000)}`,
129 ].join('\n')
130}
131types/index.d.ts 9 lines1// Active seconds per repo root and local day, e.g. { '/home/me/app': { '2026-10-19': 5_400_000 } } (milliseconds).
2export type Hours = Record<string, Record<string, number>>
3
4declare module 'claude-code' {
5 interface PluginState {
6 ledger: { hours: Hours }
7 }
8}
9