SLOPSHOPPER

worklog

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

newpanespinnerguardcommandtoast
v0.1.0no licenseupdated 2026-10-09TomasPalsson/worklog/mods/worklog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worklog
│ ┃ worklog-review ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Nothing to review today. │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 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 │ │ › /wl │ ⎿ worklog: usage: /wl today|week|review|ask <question> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · worklog-review
╭──────────────────────────────────────────────────────────╮ │ Nothing to review today. │ ╰──────────────────────────────────────────────────────────╯
README

worklog

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.

Install

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? See docs/MIGRATION.md — one step.

Architecture

┌──────────────────────────── 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.

Quickstart

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

Commands

CommandPurpose
worklog versionPrint the embedded version
worklog setupPreflight + secrets capture + db migrate
worklog doctorEnvironment, 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 --authLog 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 / buildContainer 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 upgradeSigned 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.

Google Calendar login

worklog uses your own Google OAuth client; none is built in.

  1. In Google Cloud Console, create a project and enable the Google Calendar API.
  2. Configure the OAuth consent screen and add yourself as a test user.
  3. Create credentials: OAuth client ID, application type Desktop app.
  4. Download the JSON and save it as ~/.config/worklog/google_credentials.json ($WORKLOG_HOME/google_credentials.json when $WORKLOG_HOME is set).
  5. Run 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.

Storage

  • DB: ~/.local/share/worklog/worklog.db (SQLite, WAL mode).
  • Config: ~/.config/worklog/ (.env, google_credentials.json, google_token.json).
  • Secrets: OS keychain under service name 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.
  • Binaries + releases: ~/.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.

Switching estimator provider

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.

Dev

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.

Files of interest

  • rust/crates/worklog-core/ — data layer: paths, db, repo, secrets, collectors, infer, estimator, signed updater
  • rust/crates/worklog-cli/ — the worklog binary: CLI + setup wizard
  • rust/crates/worklog-core/sql/schema.sql — canonical SQL schema
  • web/ — the Next.js + Bun review UI (dockerised)
  • install.sh — curl-piped installer
  • scripts/release-smoke.sh — host-side dry-run of the release pipeline
Source 7 files
hooks/register.tsx 11 lines
1import 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}
11
hooks/command.tsx 212 lines
1import { 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}
212
hooks/status.ts 125 lines
1import { 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}
125
hooks/ticket.ts 175 lines
1import 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}
175
hooks/contract.ts 97 lines
1// 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 }
97
hooks/lib.ts 168 lines
1import { 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}
168
types/index.d.ts 28 lines
1export 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