SLOPSHOPPER

ledger

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

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-19Egoushka/claude-mods/mods/ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ledger
│ ┃ Hours ✕ › fix the failing auth test and add an audit log call │ ┃ repo today week │ ┃ month ⏺ Read(src/auth.ts) │ ┃ app 1m 1m ⎿ Read 6 lines │ ┃ 1m ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Copy CSV ] ⏺ 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 │ │ › /hours │ ⎿ ledger: Hours pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hours
repo today week month app 1m 1m 1m [ Copy CSV ]
README

ledger

Active hours per repo, and a drafted post when you merge a pull request in a public repo.

Hours

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.

Build in public

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.

Settings

FieldDefaultMeaning
idleMinutes10A gap under this counts as active
csvPathemptyFile for the CSV export; empty copies to the clipboard
hourlyRate0Money per hour for the goal line
currencyUSDLabel on the goal line
monthlyGoal0Income goal per month; 0 turns the goal line off
publicReposempty (off)Regex over owner/name and the repo folder, e.g. ^my-org/
draftsDirempty (off)Folder for drafted posts
draftModelhaikuModel that writes the draft

Drafting needs both publicRepos and draftsDir.

Cost

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.

Limits

  • The idle rule is a heuristic: thinking time away from the keyboard inside one turn counts, and work in another tool between two prompts counts only if the gap is short.
  • Two sessions in one repo at once both count their own turns, so overlapping time is added twice.
  • The store is read and written whole on each update; two sessions writing at the same instant can lose one update.
  • The post detection reads the command text, so a merge done outside Claude Code is not seen.
Source 3 files
hooks/register.tsx 217 lines
1import { 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}
217
hooks/time.ts 131 lines
1import 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}
131
types/index.d.ts 9 lines
1// 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