Hours, reminders and ticket context from worklog inside Claude Code.

Personal time-tracker for the developer who hates timers. Pulls activity from Claude Code, GitHub, Google Calendar, and Jira into one local SQLite file, clusters it into blocks, uses claude -p to pick a ticket and write a description for each block, and syncs the result to Tempo Cloud after you review it in a local web UI.
Everything runs on your machine. Nothing leaves until you push a Tempo sync — and you review every block before that happens.
curl -fsSL https://raw.githubusercontent.com/TomasPalsson/worklog/main/install.sh | bash
The script downloads a signed release binary for your platform (macOS arm64 or linux x86_64) from GitHub Releases, verifies it, and drops it at ~/.local/bin/worklog. After that:
worklog setup # one-shot onboarding: db + secrets + Claude hook
worklog day # end-of-day: collect → infer → estimate → review UI
Upgrading is a single command and always verifies the signature:
worklog upgrade # signed self-update via the release pipeline
Migrating from the old
uv tool install? Seedocs/MIGRATION.md— one step.
┌──────────────────────────── your Mac ─────────────────────────────┐
│ │
│ ~/.local/share/worklog/worklog.db (single source of truth, │
│ WAL mode — safe RO readers │
│ during concurrent writes) │
│ ▲ ▲ │
│ │ │ │
│ writes (unix socket │ reads direct │
│ OR TCP 127.0.0.1:9323) │ via bun:sqlite │
│ │ │ │
│ ┌────────┴────────┐ ┌───┴───────────────────┐ │
│ │ worklog (Rust) │ │ Docker: worklog-web │ │
│ │ · CLI │ │ · Bun + Next.js 15 │ │
│ │ · Collectors │ │ · Server Components │ │
│ │ · Estimator │───────▶│ · Server Actions → │ │
│ │ · axum daemon │ TCP │ host.docker │ │
│ │ · web orch. │ │ .internal:9323 │ │
│ └────────┬────────┘ └───────────────────────┘ │
│ │ spawns/manages │
│ ▼ │
└─────────────────────────────────────────────────────────────────────┘
Events are classified to a Jira ticket by the AI estimator; unmatched ones appear as "unassigned" in the web UI and are reassigned by hand. The web container reads SQLite directly (WAL mode = concurrent reads are safe), and writes flow through Next.js Server Actions → the Rust daemon over TCP (Docker Desktop can't proxy live unix sockets through its macOS VM, hence TCP between the container and host).
Only the Rust daemon writes to the DB — Server Actions are a thin shim that forwards the mutation and calls revalidatePath.
worklog setup # preflight + interactive secrets + db migrate
worklog hook install # register the Claude Code hook
worklog day # full daily pipeline (collect → infer → estimate → UI)
To pull everything without running the UI:
worklog day --no-serve
| Command | Purpose | |||
|---|---|---|---|---|
worklog version | Print the embedded version | |||
worklog setup | Preflight + secrets capture + db migrate | |||
worklog doctor | Environment, DB, secrets sanity report | |||
worklog day [--day] [--no-serve] [--model] | Full daily pipeline: collect → infer → estimate → web UI | |||
| `worklog collect [all\ | jira\ | github\ | gcal] [--days]` | Pull remote activity |
worklog collect gcal --auth | Log in to Google Calendar (browser consent) | |||
worklog infer [--day] | Gap-timeout clustering of events into blocks | |||
worklog estimate [--day] [--model] | claude -p fills jira + description + minutes | |||
worklog sync [--day] [--dry-run] | POST reviewed blocks to Tempo Cloud | |||
worklog web up [--port] | Bring up the dockerised Next.js UI | |||
worklog web down / status / logs / build | Container lifecycle | |||
worklog daemon [--socket] [--tcp] | Axum API server (unix + TCP) | |||
| `worklog hook [install\ | uninstall\ | status]` | Claude Code hook wiring | |
| `worklog schedule [install\ | uninstall\ | status]` | Scheduled collection (launchd / systemd --user) | |
| `worklog secret [set\ | get\ | rm\ | list]` | Credentials in the OS keychain |
| `worklog db [migrate\ | info\ | path]` | DB operations | |
worklog upgrade | Signed self-update | |||
| `worklog self-update [--check\ | --dry-run\ | --force]` | Lower-level alias for upgrade | |
| `worklog dev [keygen\ | sign\ | make-patch\ | apply-patch]` | Maintainer tooling |
Every subcommand accepts --json for structured output.
worklog uses your own Google OAuth client; none is built in.
~/.config/worklog/google_credentials.json ($WORKLOG_HOME/google_credentials.json when $WORKLOG_HOME is set).worklog collect gcal --auth, click "Allow" in the browser, and the token is saved to google_token.json. If no browser opens, open the printed link on the same machine. The login times out after 5 minutes.Re-run the same command if Google revokes the token.
~/.local/share/worklog/worklog.db (SQLite, WAL mode).~/.config/worklog/ (.env, google_credentials.json, google_token.json).worklog (macOS Keychain, Linux secret-service, Windows Credential Manager). The .env file still works — secrets migrate lazily as you set them via worklog secret set … or re-run worklog setup.~/.local/share/worklog/{bin,releases}/.Override everything with $WORKLOG_HOME=<dir> — collapses db, socket, config, bin, logs, releases into one root. Primarily for tests and power users.
The estimator has two interchangeable backends:
claude_subprocess (default) — shells out to claude -p. Zero configuration. Requires the Claude Code CLI on PATH.litellm — POSTs to any OpenAI-compatible proxy (LiteLLM is the reference implementation). Lets you route to Anthropic, OpenAI, local Ollama, Bedrock, etc. without changing worklog.Select via worklog setup (interactive) or set the env var:
export WORKLOG_ESTIMATOR_PROVIDER=litellm
worklog secret set litellm_base_url http://localhost:4000
worklog secret set litellm_api_key <your-proxy-key> # empty OK for unauthed local
worklog secret set litellm_model anthropic/claude-haiku-4-5
worklog doctor reports the active provider and a reachability probe. Unset WORKLOG_ESTIMATOR_PROVIDER (and run worklog secret rm worklog_estimator_provider) to fall back to the subprocess path.
cargo test --manifest-path rust/Cargo.toml
cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings
cargo fmt --manifest-path rust/Cargo.toml --all
cd web && bun test && bun run typecheck && bun run build
Local release smoke (no network, no tag push):
bash scripts/release-smoke.sh
bash tests/install/smoke.sh
CI: .github/workflows/rust.yml runs fmt + clippy + tests on Linux and macOS on every push / PR; .github/workflows/release.yml builds, signs, and publishes on every v* tag.
rust/crates/worklog-core/ — data layer: paths, db, repo, secrets, collectors, infer, estimator, signed updaterrust/crates/worklog-cli/ — the worklog binary: CLI + setup wizardrust/crates/worklog-core/sql/schema.sql — canonical SQL schemaweb/ — the Next.js + Bun review UI (dockerised)install.sh — curl-piped installerscripts/release-smoke.sh — host-side dry-run of the release pipelinehooks/register.tsx 11 lines1import type { Register } from 'claude-code'
2import { registerCommand } from './command'
3import { registerStatus } from './status'
4import { registerTicket } from './ticket'
5
6export const register: Register = on => {
7 registerStatus(on)
8 registerTicket(on)
9 registerCommand(on)
10}
11hooks/command.tsx 212 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderInput } from 'claude-code'
3import { REVIEW_PANE_ID } from './contract'
4import type { DaySummary, Io, ReviewAction, WeekCloseout } from './contract'
5import {
6 daemonGet,
7 daemonPost,
8 daemonToday,
9 formatHours,
10 mondayOf,
11 reviewRequest,
12 workSeconds,
13} from './lib'
14
15type ReviewBlock = DaySummary['blocks'][number]
16
17type AskHit = { day: string; started_at: string; ended_at: string; jira_issue: string | null }
18
19type ReviewState = {
20 blocks: ReviewBlock[]
21 selected: number | undefined
22 editing: 'ticket' | 'description' | undefined
23 error: string | undefined
24}
25
26const USAGE = 'usage: /wl today|week|review|ask <question>'
27
28const review = atom(
29 { plugin: 'worklog', key: 'review' } as const,
30 { blocks: [], selected: undefined, editing: undefined, error: undefined } as ReviewState,
31)
32
33function change($: EngineInterface, patch: Partial<ReviewState>): Promise<unknown> {
34 return update($, review, state => ({ ...state, ...patch }))
35}
36
37async function makeIo($: EngineInterface): Promise<Io> {
38 return {
39 fetch: (url, init) => $.http.fetch(url, init),
40 run: (argv, cwd) => $.process.run(argv, { cwd }),
41 after: (milliseconds, callback) => $.clock.after(milliseconds, callback),
42 now: () => $.clock.now(),
43 home: await $.env.get('HOME'),
44 }
45}
46
47async function loadReview($: EngineInterface, io: Io): Promise<void> {
48 const today = await daemonToday(io)
49 const summary = today.ok ? await daemonGet<DaySummary>(io, `/days/${today.value}`) : today
50 if (!summary.ok) {
51 await change($, { error: summary.error })
52 return
53 }
54 const blocks = summary.value.blocks.filter(block => block.ignored_at === null)
55 await update($, review, state => ({
56 blocks,
57 selected: blocks.some(block => block.id === state.selected) ? state.selected : blocks[0]?.id,
58 editing: undefined,
59 error: undefined,
60 }))
61}
62
63async function sendReview(
64 $: EngineInterface,
65 io: Io,
66 toAction: (block: ReviewBlock) => ReviewAction,
67): Promise<void> {
68 const { blocks, selected } = await read($, review)
69 const target = blocks.find(block => block.id === selected)
70 if (target === undefined) return
71 const request = reviewRequest(target.id, toAction(target))
72 const result = await daemonPost<unknown>(io, request.path, request.body)
73 if (result.ok) await loadReview($, io)
74 else await change($, { error: result.error })
75}
76
77async function summarise($: EngineInterface, io: Io, period: 'today' | 'week'): Promise<string> {
78 const today = await daemonToday(io)
79 if (!today.ok) return `worklog: ${today.error}`
80 if (period === 'today') {
81 const summary = await daemonGet<DaySummary>(io, `/days/${today.value}`)
82 return summary.ok
83 ? `worklog today: ${formatHours(workSeconds(summary.value.blocks))}`
84 : `worklog: ${summary.error}`
85 }
86 const week = await daemonGet<WeekCloseout>(io, `/weeks/${mondayOf(today.value)}/closeout`)
87 if (!week.ok) return `worklog: ${week.error}`
88 const seconds = week.value.days.reduce((total, day) => total + day.logged_seconds, 0)
89 return `worklog week: ${formatHours(seconds)}`
90}
91
92async function ask(io: Io, question: string): Promise<string> {
93 const hits = await daemonGet<AskHit[]>(io, `/ask?q=${encodeURIComponent(question)}`)
94 if (!hits.ok) return `worklog: ${hits.error}`
95 if (hits.value.length === 0) return 'worklog ask: no matches'
96 return hits.value
97 .map(hit => `${hit.day} ${hit.started_at.slice(11, 16)}–${hit.ended_at.slice(11, 16)} ${hit.jira_issue ?? '—'}`)
98 .join('\n')
99}
100
101function typedAction(editing: 'ticket' | 'description', value: string): ReviewAction {
102 return editing === 'ticket'
103 ? { kind: 'ticket', jira_issue: value.trim() === '' ? null : value.trim() }
104 : { kind: 'description', description: value }
105}
106
107function blockLabel(block: ReviewBlock): string {
108 const description = block.description ?? ''
109 const text = description.length > 40 ? `${description.slice(0, 39)}…` : description
110 const time = `${block.started_at.slice(11, 16)}–${block.ended_at.slice(11, 16)}`
111 const hours = formatHours(block.duration_seconds).padStart(5)
112 const ticket = (block.jira_issue ?? '—').padEnd(12)
113 return `${time} ${hours} ${ticket} ${block.is_personal ? 'personal ' : ''}${text}`.trimEnd()
114}
115
116async function reviewPane($: EngineInterface, event: RenderInput<'Pane'>) {
117 const { Box, Text, Button, Input, Select } = $.ui.resolve(event)
118 const io = await makeIo($)
119 const { blocks, editing, error, selected } = await read($, review)
120 const current = blocks.find(block => block.id === selected)
121 const personalSeconds = blocks
122 .filter(block => block.is_personal)
123 .reduce((total, block) => total + block.duration_seconds, 0)
124
125 return (
126 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
127 {current === undefined ? (
128 <>
129 {error !== undefined && <Text color="red">{error}</Text>}
130 <Text dimColor>Nothing to review today.</Text>
131 </>
132 ) : (
133 <>
134 <Box justifyContent="space-between">
135 <Text bold>{current.day}</Text>
136 <Text dimColor>
137 work {formatHours(workSeconds(blocks))} · personal {formatHours(personalSeconds)}
138 </Text>
139 </Box>
140 {error !== undefined && <Text color="red">{error}</Text>}
141 <Box marginY={1}>
142 <Select
143 key="block"
144 value={String(current.id)}
145 options={blocks.map(block => ({ value: String(block.id), label: blockLabel(block) }))}
146 onSelect={value => change($, { selected: Number(value) })}
147 />
148 </Box>
149 <Box gap={3}>
150 <Button plain key="personal" hotkey="p" onPress={() => sendReview($, io, block => ({ kind: 'personal', is_personal: !block.is_personal }))}>
151 {current.is_personal ? 'work' : 'personal'}
152 </Button>
153 <Button plain key="ignore" hotkey="i" onPress={() => sendReview($, io, () => ({ kind: 'ignore', ignored: true }))}>
154 ignore
155 </Button>
156 <Button plain key="ticket" hotkey="t" onPress={() => change($, { editing: 'ticket' })}>
157 ticket
158 </Button>
159 <Button plain key="description" hotkey="d" onPress={() => change($, { editing: 'description' })}>
160 description
161 </Button>
162 </Box>
163 {editing !== undefined && (
164 <Box marginTop={1} gap={2}>
165 <Input
166 key="edit"
167 label={editing}
168 value={(editing === 'ticket' ? current.jira_issue : current.description) ?? ''}
169 onSubmit={value => sendReview($, io, () => typedAction(editing, value))}
170 onCancel={() => change($, { editing: undefined })}
171 />
172 <Button plain dimColor key="cancel" onPress={() => change($, { editing: undefined })}>
173 cancel
174 </Button>
175 </Box>
176 )}
177 </>
178 )}
179 </Box>
180 )
181}
182
183export function registerCommand(on: On): void {
184 on('session.start', { isInteractive: true }, async ($, event, next) => {
185 await $.command.register({
186 name: 'wl',
187 description: 'Show logged hours, or review today in a pane',
188 argumentHint: 'today|week|review|ask',
189 })
190 return next(event)
191 })
192
193 on('command.run', { command: 'wl' }, async ($, event) => {
194 const argument = event.args.trim()
195 const question = /^ask\s+(\S[\s\S]*)$/.exec(argument)?.[1]
196 if (question !== undefined) return { text: await ask(await makeIo($), question) }
197 if (argument !== 'today' && argument !== 'week' && argument !== 'review') {
198 return { text: USAGE }
199 }
200 const io = await makeIo($)
201 if (argument !== 'review') return { text: await summarise($, io, argument) }
202 await change($, { selected: undefined })
203 await loadReview($, io)
204 await $.ui.open({ id: REVIEW_PANE_ID, title: 'worklog review', focus: true, closeOnEscape: true })
205 return { text: 'worklog review opened' }
206 })
207
208 on('ui.render', { component: 'Pane', requestId: REVIEW_PANE_ID }, ($, event) =>
209 reviewPane($, event),
210 )
211}
212hooks/status.ts 125 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import { DEFAULT_REQUIRED_SECONDS, NUDGE_STORE_PREFIX, POLL_MS } from './contract'
4import type { DaySummary, Io, WeekCloseout } from './contract'
5import {
6 daemonGet,
7 daemonToday,
8 formatHours,
9 lastWorkday,
10 mondayOf,
11 workContext,
12 workSeconds,
13} from './lib'
14
15const hours = atom(
16 { plugin: 'worklog', key: 'hours' } as const,
17 '',
18)
19
20const nudgeLine = atom(
21 { plugin: 'worklog', key: 'nudge' } as const,
22 '',
23)
24
25/** GET /recap, the fields the mod reads. */
26type Recap = {
27 day: string
28 sent: unknown[]
29 held_back: unknown[]
30 coverage_percent: number
31 gaps: unknown[]
32}
33
34const recapState = atom(
35 { plugin: 'worklog', key: 'recap' } as const,
36 { line: '', gaps: 0 },
37)
38
39async function makeIo($: EngineInterface): Promise<Io> {
40 return {
41 fetch: (url, init) => $.http.fetch(url, init),
42 run: (argv, cwd) => $.process.run(argv, { cwd }),
43 after: (milliseconds, callback) => $.clock.after(milliseconds, callback),
44 now: () => $.clock.now(),
45 home: await $.env.get('HOME'),
46 }
47}
48
49export function registerStatus(on: On): void {
50 on('session.start', async ($, event, next) => {
51 const io = await makeIo($)
52
53 async function showHours(): Promise<void> {
54 const today = await daemonToday(io)
55 const [summary, recap] = today.ok
56 ? await Promise.all([daemonGet<DaySummary>(io, `/days/${today.value}`), daemonGet<Recap | null>(io, '/recap')])
57 : [today, today]
58 const text = summary.ok ? `worklog ${formatHours(workSeconds(summary.value.blocks))}` : ''
59 await update($, hours, () => text)
60 const current = recap.ok && recap.value?.day === (today.ok ? today.value : '') ? recap.value : null
61 const shown = current
62 ? {
63 line: [
64 `recap: ${current.sent.length} sent`,
65 `${current.held_back.length} held`,
66 `${current.coverage_percent}%`,
67 ...(current.gaps.length > 0 ? [`${current.gaps.length} gaps`] : []),
68 ].join(' · '),
69 gaps: current.gaps.length,
70 }
71 : { line: '', gaps: 0 }
72 await update($, recapState, () => shown)
73 }
74
75 async function remind(): Promise<void> {
76 const context = await workContext(io, event.cwd)
77 const today = await daemonToday(io)
78 if (!context.isWork || !today.ok) return
79 const marker = `${NUDGE_STORE_PREFIX}${today.value}`
80 if (await $.store.get(marker)) return
81 const previous = lastWorkday(today.value)
82 const week = await daemonGet<WeekCloseout>(io, `/weeks/${mondayOf(previous)}/closeout`)
83 const day = week.ok ? week.value.days.find(candidate => candidate.day === previous) : undefined
84 if (day === undefined) return
85 const required = day.required_seconds ?? DEFAULT_REQUIRED_SECONDS
86 if (required === 0) return
87 const problems: string[] = []
88 if (day.pending_lines > 0) problems.push(`${day.pending_lines} unsynced lines`)
89 const worked = Math.max(day.logged_seconds, day.tempo_seconds)
90 if (worked < required) {
91 problems.push(`${formatHours(worked)} logged of ${formatHours(required)}`)
92 }
93 if (problems.length === 0) return
94 await $.ui.toast(`worklog: ${previous} has ${problems.join(' and ')}`)
95 await $.store.set(marker, true)
96 }
97
98 void showHours()
99 void remind()
100 $.clock.every(POLL_MS, () => {
101 void showHours()
102 })
103 return next(event)
104 })
105
106 let prompts = 0
107 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, event, next) => {
108 const turn = prompts++
109 const io = await makeIo($)
110 void (async () => {
111 const list = await daemonGet<{ text: string }[]>(io, '/nudges')
112 const shown = list.ok && list.value.length > 0 ? list.value[turn % list.value.length].text : ''
113 await update($, nudgeLine, () => shown)
114 })()
115 return next(event)
116 })
117
118 on('ui.render', { component: 'PromptHint' }, async ($, event, next) => {
119 const recap = await read($, recapState)
120 const nudgeText = recap.gaps > 0 ? '' : await read($, nudgeLine)
121 const parts = [event.props.tail, await read($, hours), recap.line, nudgeText].filter(Boolean)
122 return next(parts.length > 0 ? { ...event, props: { ...event.props, tail: parts.join(' · ') } } : event)
123 })
124}
125hooks/ticket.ts 175 lines1import type { EngineInterface, On } from 'claude-code'
2import { JIRA_KEY_RE, TICKET_ASKED_STORE_PREFIX } from './contract'
3import type { Io, RecentTask } from './contract'
4import { daemonGet, keyOfChoice, ticketChoices, workContext } from './lib'
5
6const TASKS_CAP_MS = 1500
7const PICK_CAP_MS = 8000
8const LABEL_MAX = 60
9const EXACT_KEY_RE = new RegExp(`^${JIRA_KEY_RE.source}$`)
10
11async function makeIo($: EngineInterface): Promise<Io> {
12 return {
13 fetch: (url, init) => $.http.fetch(url, init),
14 run: (argv, cwd) => $.process.run(argv, { cwd }),
15 after: (milliseconds, callback) => $.clock.after(milliseconds, callback),
16 now: () => $.clock.now(),
17 home: await $.env.get('HOME'),
18 }
19}
20
21type TicketState = {
22 cwd: string | undefined
23 ticket: string | undefined
24 askable: boolean
25 recorded: string | undefined
26 handoff: string | undefined
27}
28
29const handoffFor = (answer: string, id: string): string =>
30 answer === 'Create a new ticket'
31 ? `Instruction "create a ticket": the Owner wants a new Jira ticket for this session. Once the first request makes the task clear, create it (worklog skill, Jira recipe, one confirm), then run worklog ticket use <KEY> --session ${id}.`
32 : `Instruction "find the ticket for: ${answer}": run worklog ticket find, confirm the match with the Owner, then run worklog ticket use <KEY> --session ${id}.`
33
34async function refresh($: EngineInterface, state: TicketState): Promise<boolean> {
35 if (state.cwd === undefined) return false
36 const context = await workContext(await makeIo($), state.cwd)
37 state.ticket = context.isWork ? context.ticket : undefined
38 return context.isWork
39}
40
41async function recentTasks(io: Io): Promise<RecentTask[]> {
42 let timer: { cancel: () => void } | undefined
43 const cap = new Promise<undefined>(resolve => {
44 timer = io.after(TASKS_CAP_MS, () => resolve(undefined))
45 })
46 try {
47 const result = await Promise.race([daemonGet<{ tasks: RecentTask[] }>(io, '/tasks'), cap])
48 return result?.ok && Array.isArray(result.value.tasks) ? result.value.tasks : []
49 } finally {
50 timer?.cancel()
51 }
52}
53
54const cut = (label: string): string =>
55 label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 1)}…` : label
56
57type Pick = { picked: string | undefined; likely: string | undefined }
58
59// Verdict reads the first prompt; any failure means "no pick" and the Owner is asked.
60async function verdictPick($: EngineInterface, cwd: string, id: string, ticket: string | undefined, text: string): Promise<Pick> {
61 const none: Pick = { picked: undefined, likely: undefined }
62 let timer: { cancel: () => void } | undefined
63 const cap = new Promise<undefined>(resolve => {
64 timer = $.clock.after(PICK_CAP_MS, () => resolve(undefined))
65 })
66 try {
67 const argv = ['worklog', '--json', 'ticket', 'pick', '--session', id, ...(ticket ? ['--also', ticket] : []), '--', text]
68 const result = await Promise.race([$.process.run(argv, { cwd }), cap])
69 if (result === undefined || result.exitCode !== 0) return none
70 const body = JSON.parse(result.stdout.trim()) as { picked?: unknown; likely?: unknown }
71 const key = (value: unknown) => (typeof value === 'string' && EXACT_KEY_RE.test(value) ? value : undefined)
72 return { picked: key(body.picked), likely: key(body.likely) }
73 } catch {
74 return none
75 } finally {
76 timer?.cancel()
77 }
78}
79
80async function askTicket($: EngineInterface, cwd: string, state: TicketState, text: string): Promise<void> {
81 const id = await $.session.id()
82 const marker = `${TICKET_ASKED_STORE_PREFIX}${id}`
83 if (await $.store.get(marker)) return
84 const { picked, likely } = await verdictPick($, cwd, id, state.ticket, text)
85 if (picked !== undefined) {
86 state.recorded = picked
87 state.handoff = undefined
88 await $.store.set(marker, picked)
89 await $.ui.toast(`worklog: ${picked} (Verdict)`)
90 return
91 }
92 const options = ticketChoices(state.ticket, await recentTasks(await makeIo($)), likely).map(cut)
93 let answer: string
94 try {
95 answer = await $.ui.ask('Which Jira ticket is this session for?', { options, header: 'Ticket' })
96 } catch {
97 await $.store.set(marker, true)
98 await $.ui.log(`worklog: no ticket question shown — run worklog ticket use KEY --session ${id}`)
99 return
100 }
101 await $.store.set(marker, answer)
102 const key = options.includes(answer) ? keyOfChoice(answer) : EXACT_KEY_RE.exec(answer)?.[1]
103 if (key === undefined) {
104 if (answer !== 'Skip') state.handoff = handoffFor(answer, id)
105 return
106 }
107 try {
108 const result = await $.process.run(['worklog', 'ticket', 'use', key, '--session', id], { cwd })
109 if (result.exitCode !== 0) throw new Error(result.stderr.trim())
110 state.recorded = key
111 state.handoff = undefined
112 await $.ui.toast(`worklog: ${key}`)
113 } catch (error) {
114 await $.ui.log(`worklog: could not record ${key} — ${error instanceof Error ? error.message : String(error)}`)
115 }
116}
117
118export function registerTicket(on: On): void {
119 const state: TicketState = { cwd: undefined, ticket: undefined, askable: false, recorded: undefined, handoff: undefined }
120
121 on('session.start', { surface: 'terminal' }, async ($, event, next) => {
122 state.cwd = event.cwd
123 const isWork = await refresh($, state)
124 if (state.ticket !== undefined) await $.ui.toast(`worklog: ${state.ticket}`)
125 state.askable = isWork && event.isInteractive
126 return next(event)
127 })
128
129 on('session.end', async ($, event, next) => {
130 if (event.reason === 'clear') {
131 state.recorded = undefined
132 state.handoff = undefined
133 }
134 return next(event)
135 })
136
137 // The first prompt (and the first after /clear) picks or asks the ticket before it enters.
138 on('prompt.submit', async ($, event, next) => {
139 if (state.askable && state.cwd !== undefined) await askTicket($, state.cwd, state, event.text).catch(() => undefined)
140 return next(event)
141 })
142
143 on('prompt.context', async ($, event, next) => {
144 const result = await next(event)
145 const ticket = state.recorded ?? state.ticket
146 const text = state.recorded === undefined ? `Current branch ticket: ${ticket}` : `Session ticket: ${ticket}`
147 const lines = [ticket === undefined ? undefined : text, state.handoff].filter(l => l !== undefined)
148 return lines.length === 0 ? result : { ...result, blocks: [...result.blocks, { name: 'worklog', text: lines.join('\n') }] }
149 })
150
151 for (const kind of ['commit', 'pr'] as const) {
152 on('attribution.text', { kind }, async ($, event, next) => {
153 const result = await next(event)
154 const ticket = state.recorded ?? state.ticket
155 return ticket === undefined ? result : { ...result, text: `${result.text}\n\nTicket: ${ticket}` }
156 })
157 }
158
159 on('tool.call', { tool: 'Bash' }, async ($, event, next) => {
160 const result = await next(event)
161 if (result.deny !== undefined || result.isError === true) return result
162 const [, key, id] = /worklog ticket use (\S+) --session (\S+)/.exec(event.command) ?? []
163 if (key !== undefined && EXACT_KEY_RE.test(key) && id === (await $.session.id())) {
164 state.recorded = key
165 state.handoff = undefined
166 }
167 return result
168 })
169
170 on('turn.complete', async ($, event, next) => {
171 void refresh($, state)
172 return next(event)
173 })
174}
175hooks/contract.ts 97 lines1// Shared shapes for the worklog Claude Code mod. Owned by the spec
2// orchestrator: task code imports from here and never redeclares these.
3// Field names mirror the daemon's JSON exactly (rust/crates/worklog-core).
4
5export const DAEMON_URL = 'http://127.0.0.1:9323'
6export const POLL_MS = 60_000
7export const DEFAULT_REQUIRED_SECONDS = 8 * 3600
8export const NUDGE_STORE_PREFIX = 'nudge:'
9export const TICKET_ASKED_STORE_PREFIX = 'ticket-asked:'
10export const REVIEW_PANE_ID = 'worklog-review'
11export const JIRA_KEY_RE = /\b([A-Z][A-Z0-9]{1,9}-\d+)\b/
12
13/** A local calendar day, `YYYY-MM-DD`, in the daemon's WORKLOG_TZ. */
14export type LocalDay = string
15
16/** What a session knows about where it runs; computed from the cwd. */
17export type WorkContext = {
18 cwd: string
19 /** Under ~/Desktop/Work/ or the git `origin` remote is in the aproorg org. */
20 isWork: boolean
21 branch: string | undefined
22 /** First Jira key in the branch name, if any. */
23 ticket: string | undefined
24}
25
26/** `Block` from models.rs, the fields the mod reads. */
27export type Block = {
28 id: number
29 day: LocalDay
30 jira_issue: string | null
31 started_at: string
32 ended_at: string
33 duration_seconds: number
34 description: string | null
35 is_personal: boolean
36 ignored_at: string | null
37 tempo_worklog_id: string | null
38 exported_at: string | null
39}
40
41/** GET /days/:day — `DaySummary`, the fields the mod reads. */
42export type DaySummary = {
43 day: LocalDay
44 total_seconds: number
45 blocks: (Block & { project: string | null })[]
46}
47
48/** GET /logged?from&to — `LoggedRange`; the mod reads only `today`. */
49export type LoggedRange = { today: LocalDay }
50
51/** `GET /tasks` entry, the fields the mod reads. */
52export type RecentTask = {
53 key: string
54 summary: string
55 assigned: boolean
56 last_worked_day: string | null
57}
58
59/** One day of GET /weeks/:monday/closeout. */
60export type CloseoutDay = {
61 day: LocalDay
62 logged_seconds: number
63 synced_seconds: number
64 tempo_seconds: number
65 required_seconds: number | null
66 pending_lines: number
67}
68
69/** GET /weeks/:monday/closeout, the fields the mod reads. */
70export type WeekCloseout = { days: CloseoutDay[] }
71
72/**
73 * The engine capabilities lib.ts needs. The validator only follows `$` into
74 * functions declared in the same file, so lib.ts never receives `$`: each hook
75 * file builds an `Io` from closures over its own `$` and passes that instead.
76 */
77export type Io = {
78 fetch: (
79 url: string,
80 init?: { method?: string; headers?: Record<string, string>; body?: string },
81 ) => Promise<{ status: number; ok: boolean; text: string }>
82 run: (argv: string[], cwd: string) => Promise<{ exitCode: number; stdout: string }>
83 after: (ms: number, fn: () => void) => { cancel(): void }
84 now: () => Promise<number>
85 home: string | undefined
86}
87
88/** Every daemon call resolves to this; never throws. */
89export type DaemonResult<T> = { ok: true; value: T } | { ok: false; error: string }
90
91/** The four review actions (D-09) and their daemon routes + bodies. */
92export type ReviewAction =
93 | { kind: 'personal'; is_personal: boolean }
94 | { kind: 'ignore'; ignored: boolean }
95 | { kind: 'ticket'; jira_issue: string | null }
96 | { kind: 'description'; description: string }
97hooks/lib.ts 168 lines1import { DAEMON_URL, JIRA_KEY_RE } from './contract'
2import type {
3 Block,
4 DaemonResult,
5 Io,
6 LocalDay,
7 RecentTask,
8 ReviewAction,
9 WorkContext,
10} from './contract'
11
12const TIMEOUT_MS = 2000
13const DAY_MS = 24 * 3600 * 1000
14const WORK_ORIGIN_RE = /github\.com[:/]aproorg\//
15
16function shiftDay(day: LocalDay, days: number): LocalDay {
17 return new Date(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
18}
19
20function weekdayOf(day: LocalDay): number {
21 return new Date(`${day}T00:00:00Z`).getUTCDay()
22}
23
24export function lastWorkday(today: LocalDay): LocalDay {
25 const weekday = weekdayOf(today)
26 if (weekday === 1) return shiftDay(today, -3)
27 if (weekday === 0) return shiftDay(today, -2)
28 return shiftDay(today, -1)
29}
30
31export function mondayOf(day: LocalDay): LocalDay {
32 return shiftDay(day, -((weekdayOf(day) + 6) % 7))
33}
34
35export function workSeconds(blocks: readonly Block[]): number {
36 return blocks
37 .filter(block => !block.is_personal && block.ignored_at === null)
38 .reduce((total, block) => total + block.duration_seconds, 0)
39}
40
41export function formatHours(seconds: number): string {
42 const minutes = Math.round(seconds / 60)
43 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
44}
45
46export function ticketFromBranch(branch: string | undefined): string | undefined {
47 return branch?.match(JIRA_KEY_RE)?.[1]
48}
49
50export function ticketChoices(
51 branchTicket: string | undefined,
52 recentTasks: readonly RecentTask[],
53 likely?: string,
54): string[] {
55 const label = (key: string) => {
56 const task = recentTasks.find((t) => t.key === key)
57 return task ? `${key} ${task.summary}` : key
58 }
59 const lead = [likely, branchTicket !== likely ? branchTicket : undefined].filter(
60 (key): key is string => key !== undefined,
61 )
62 const recent = recentTasks
63 .filter((task) => task.assigned && !lead.includes(task.key))
64 .sort((a, b) => (b.last_worked_day ?? '').localeCompare(a.last_worked_day ?? ''))
65 .slice(0, Math.max(0, 2 - lead.length))
66 .map((task) => `${task.key} ${task.summary}`)
67 return [
68 ...lead.map((key) => (key === likely ? label(key) : key)),
69 ...recent,
70 'Create a new ticket',
71 'Skip',
72 ]
73}
74
75export function keyOfChoice(label: string): string | undefined {
76 return label.match(new RegExp(`^${JIRA_KEY_RE.source}(?:\\s|$)`))?.[1]
77}
78
79export function reviewRequest(
80 blockId: number,
81 action: ReviewAction,
82): { path: string; body: unknown } {
83 const path = `/blocks/${blockId}`
84 switch (action.kind) {
85 case 'personal':
86 return { path: `${path}/personal`, body: { is_personal: action.is_personal } }
87 case 'ignore':
88 return { path: `${path}/ignore`, body: { ignored: action.ignored } }
89 case 'ticket':
90 return { path: `${path}/ticket`, body: { jira_issue: action.jira_issue } }
91 case 'description':
92 return { path: `${path}/description`, body: { description: action.description } }
93 }
94}
95
96async function daemonRequest<T>(
97 io: Io,
98 path: string,
99 init?: { method: string; headers: Record<string, string>; body: string },
100): Promise<DaemonResult<T>> {
101 const request = async (): Promise<DaemonResult<T>> => {
102 try {
103 const response = await io.fetch(`${DAEMON_URL}${path}`, init)
104 let body: unknown
105 try {
106 body = JSON.parse(response.text)
107 } catch {
108 body = undefined
109 }
110 if (response.ok && body !== undefined) return { ok: true, value: body as T }
111 const message = (body as { error?: unknown } | undefined)?.error
112 return { ok: false, error: typeof message === 'string' ? message : `HTTP ${response.status}` }
113 } catch (error) {
114 return { ok: false, error: error instanceof Error ? error.message : String(error) }
115 }
116 }
117 let timer: { cancel: () => void } | undefined
118 const timeout = new Promise<DaemonResult<T>>(resolve => {
119 timer = io.after(TIMEOUT_MS, () => resolve({ ok: false, error: 'timeout' }))
120 })
121 try {
122 return await Promise.race([request(), timeout])
123 } finally {
124 timer?.cancel()
125 }
126}
127
128export function daemonGet<T>(io: Io, path: string): Promise<DaemonResult<T>> {
129 return daemonRequest<T>(io, path)
130}
131
132export function daemonPost<T>(io: Io, path: string, body: unknown): Promise<DaemonResult<T>> {
133 return daemonRequest<T>(io, path, {
134 method: 'POST',
135 headers: { 'content-type': 'application/json' },
136 body: JSON.stringify(body),
137 })
138}
139
140export async function daemonToday(io: Io): Promise<DaemonResult<LocalDay>> {
141 const utcDay = new Date(await io.now()).toISOString().slice(0, 10)
142 const result = await daemonGet<{ today: LocalDay }>(io, `/logged?from=${utcDay}&to=${utcDay}`)
143 return result.ok ? { ok: true, value: result.value.today } : result
144}
145
146async function gitOutput(io: Io, cwd: string, argv: string[]): Promise<string | undefined> {
147 try {
148 const result = await io.run(['git', ...argv], cwd)
149 return result.exitCode === 0 ? result.stdout.trim() : undefined
150 } catch {
151 return undefined
152 }
153}
154
155export async function workContext(io: Io, cwd: string): Promise<WorkContext> {
156 const workFolder = io.home === undefined ? undefined : `${io.home}/Desktop/Work`
157 const inWorkFolder =
158 workFolder !== undefined && (cwd === workFolder || cwd.startsWith(`${workFolder}/`))
159 const branch = (await gitOutput(io, cwd, ['branch', '--show-current'])) || undefined
160 const origin = inWorkFolder ? undefined : await gitOutput(io, cwd, ['remote', 'get-url', 'origin'])
161 return {
162 cwd,
163 isWork: inWorkFolder || WORK_ORIGIN_RE.test(origin ?? ''),
164 branch,
165 ticket: ticketFromBranch(branch),
166 }
167}
168types/index.d.ts 28 lines1export type WorklogReviewBlock = {
2 id: number
3 day: string
4 jira_issue: string | null
5 started_at: string
6 ended_at: string
7 duration_seconds: number
8 description: string | null
9 is_personal: boolean
10 ignored_at: string | null
11 tempo_worklog_id: string | null
12 exported_at: string | null
13 project: string | null
14}
15
16export type WorklogReview = {
17 blocks: WorklogReviewBlock[]
18 selected: number | undefined
19 editing: 'ticket' | 'description' | undefined
20 error: string | undefined
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 worklog: { review: WorklogReview; hours: string }
26 }
27}
28