SLOPSHOPPER

work-journal

Daily, weekly and monthly reviews of your work in Claude Code, summarized from the prompts you typed: projects, threads, decisions and open items. /journal…

newpanecommandtoastmodelprocess
★ 1v0.1.2MITupdated 2026-10-07tomshaw/claude-code-plugins/mods/work-journal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-journal
│ ┃ Work journal ✕ › fix the failing auth test and add an audit log call │ ┃ 📓 Work journal · Daily review │ ┃ Thu Oct 9, 2025 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⋯ Reading your prompts and summarizing them. ⏺ Update(src/auth.ts) │ ┃ A month can take a minute. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ d: Day w: Week m: Month p: ◀ Prev n: Nex ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /journal │ ⎿ work-journal: Work journal: Thu Oct 9, 2025. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work journal
📓 Work journal · Daily review Thu Oct 9, 2025 ⋯ Reading your prompts and summarizing them. A month can take a minute. d: Day w: Week m: Month p: ◀ Prev n: Next ▶ r: Refresh
README

<h1 align="center">Claude Code plugins</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml"><img src="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-mods">Mods</a> · <a href="#-skills">Skills</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

🚀 Install

Add the marketplace once, then install any plugin by name:

claude plugin marketplace add tomshaw/claude-code-plugins
claude plugin install proofread@tomshaw
claude plugin install work-journal@tomshaw
claude plugin install laravel-tooling@tomshaw
claude plugin install commit-msg@tomshaw

Or from inside Claude Code:

/plugin install proofread --marketplace tomshaw/claude-code-plugins

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/tomshaw/claude-code-plugins && cd claude-code-plugins
claude --plugin-dir mods/proofread

Needs Claude Code 2.1.293+.

🧩 Mods

Plugins built on function hooks: they change what Claude Code shows and does, live.

ModWhat it doesCommand
✍️proofreadFixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out/proofread
📓work-journalDaily, weekly and monthly reviews of your work, summarized from the prompts you typed: projects, threads, decisions and open items. In a pane, or as a webpage/journal
⚒️laravel-toolingRuns Pint and Larastan after Claude edits PHP, and Pest on demand, with the results in a live pane and the status line/laravel-tooling
📝commit-msgWrites the commit message for your staged changes from the Claude session that made them, so it says why, not just what. Matches your repo's commit style; edit, regenerate and copy it/commit-msg

🧠 Skills

Instructions Claude loads when your request matches them. Ask in plain words.

SkillWhat it doesTry
📊commit-summarySummarizes git commits for a day, week or month, for everyone or one author, grouped by theme with line counts and highlights"what did I do this week?"

📚 More

  • 📖 docs/mods.md: how the mods behave, setup notes and build your own
  • 🧠 docs/skills.md: what each skill does and how to ask for it
  • 🤝 CONTRIBUTING.md: where each kind of plugin goes, and how to add one
Source 6 files
hooks/register.tsx 373 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Bucket, JournalWindow, ProjectReview, Review, Status } from '../types'
5import type { ProjectEntries } from './history'
6import { activeMinutes, activity, filterScript, formatMinutes, groupByProject, parseHistory } from './history'
7import { PROJECT_COLORS, renderHtml } from './html'
8import { emptyProject, parseReply, summaryRequest, threadLimit } from './summarize'
9import { bounds, fileStem, isComplete, localDate, parseArgs, shiftWindow, windowFor } from './window'
10
11const PANE = 'work-journal'
12const MAX_PROJECTS = 6
13const FRESH_MS = 15 * 60_000
14const CACHE_LIMIT = 60
15const SPARKS = ' ▁▂▃▄▅▆▇█'
16const KIND_TITLE = { day: 'Daily', week: 'Weekly', month: 'Monthly' } as const
17
18const view = atom({ plugin: 'work-journal', key: 'view' } as const, null)
19const current = atom({ plugin: 'work-journal', key: 'review' } as const, null)
20const status = atom({ plugin: 'work-journal', key: 'status' } as const, { phase: 'idle' })
21
22const plural = (count: number, word: string): string => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`
23
24export const sparkline = (buckets: readonly Bucket[]): string => {
25  const peak = Math.max(1, ...buckets.map(bucket => bucket.count))
26
27  return buckets.map(bucket => (bucket.count === 0 ? '·' : (SPARKS[Math.max(1, Math.round((bucket.count / peak) * 8))] ?? '█'))).join('')
28}
29
30const settings = { excluded: [] as string[], model: 'opus' }
31let offsetMinutes: number | undefined
32let loadToken = 0
33
34async function offset($: EngineInterface): Promise<number> {
35  if (offsetMinutes === undefined) {
36    const zone = (await $.process.run(['date', '+%z'])).stdout.trim()
37    const match = /^([+-])(\d{2})(\d{2})$/.exec(zone)
38    offsetMinutes = match === null ? 0 : (match[1] === '-' ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3]))
39  }
40
41  return offsetMinutes
42}
43
44async function today($: EngineInterface): Promise<string> {
45  return localDate(await $.clock.now(), await offset($))
46}
47
48async function home($: EngineInterface): Promise<string> {
49  return (await $.env.get('HOME')) ?? ''
50}
51
52async function setStatus($: EngineInterface, next: Status): Promise<void> {
53  await update($, status, () => next)
54}
55
56async function cached($: EngineInterface, window: JournalWindow, now: number): Promise<Review | undefined> {
57  const review = (await $.store.get(`review:${fileStem(window)}`)) as Review | undefined
58  if (review === undefined) {
59    return undefined
60  }
61
62  return isComplete(window, await today($)) || now - review.generatedAt < FRESH_MS ? review : undefined
63}
64
65async function remember($: EngineInterface, review: Review): Promise<void> {
66  const key = `review:${fileStem(review.window)}`
67  await $.store.set(key, review)
68  const keys = ((await $.store.get('reviews')) as string[] | undefined) ?? []
69  const kept = [...keys.filter(one => one !== key), key]
70  for (const old of kept.slice(0, Math.max(0, kept.length - CACHE_LIMIT))) {
71    await $.store.delete(old)
72  }
73  await $.store.set('reviews', kept.slice(-CACHE_LIMIT))
74}
75
76async function summarizeProject($: EngineInterface, project: ProjectEntries, window: JournalWindow, zone: number): Promise<ProjectReview> {
77  const base = emptyProject(project)
78  const request = summaryRequest(project, window, zone)
79  if (request === null) {
80    return { ...base, summary: 'Only commands and short replies in this window.' }
81  }
82
83  const reply = await $.model.complete({ model: settings.model, ...request, maxTokens: 6000, timeoutMs: 120_000 })
84  if (!reply.isAnswered) {
85    return { ...base, summary: `Couldn't summarize (${reply.reason}).` }
86  }
87
88  const parsed = parseReply(reply.text)
89  if (parsed === null) {
90    const path = `${await home($)}/.claude/work-journal/debug/${fileStem(window)}-${project.name}.txt`
91    await $.fs.write(path, `output tokens: ${reply.usage.output_tokens}\n\n${reply.text}`).catch(() => undefined)
92
93    return { ...base, summary: `Couldn't read the summary the model wrote. Its answer is saved in ${path}` }
94  }
95
96  return { ...base, summary: parsed.summary, threads: parsed.threads.slice(0, threadLimit(window)) }
97}
98
99async function build($: EngineInterface, window: JournalWindow): Promise<Review> {
100  const zone = await offset($)
101  const { from, to } = bounds(window, zone)
102  const ran = await $.process.run([...filterScript(from, to), `${await home($)}/.claude/history.jsonl`], { timeoutMs: 20_000 })
103  if (ran.exitCode !== 0) {
104    throw new Error(ran.stderr.trim() || 'Could not read ~/.claude/history.jsonl')
105  }
106
107  const entries = parseHistory(ran.stdout, settings.excluded)
108  const projects = groupByProject(entries)
109  const summarized = await Promise.all(projects.slice(0, MAX_PROJECTS).map(project => summarizeProject($, project, window, zone)))
110  const lead = summarized[0]
111
112  return {
113    window,
114    generatedAt: await $.clock.now(),
115    prompts: entries.length,
116    activeMinutes: activeMinutes(entries),
117    headline: lead === undefined ? 'No prompts in this window.' : lead.summary,
118    activity: activity(entries, window, zone),
119    projects: summarized,
120    skippedProjects: projects.slice(MAX_PROJECTS).map(project => ({ name: project.name, prompts: project.entries.length })),
121  }
122}
123
124async function load($: EngineInterface, window: JournalWindow, isForced: boolean): Promise<Review | undefined> {
125  const token = ++loadToken
126  await update($, view, () => window)
127  const hit = isForced ? undefined : await cached($, window, await $.clock.now())
128  if (hit !== undefined) {
129    await update($, current, () => hit)
130    await setStatus($, { phase: 'ready' })
131
132    return hit
133  }
134
135  await setStatus($, { phase: 'loading', message: `Summarizing ${window.label}…` })
136  try {
137    const review = await build($, window)
138    if (token !== loadToken) {
139      return undefined
140    }
141    if (!review.projects.some(project => project.summary.startsWith("Couldn't"))) {
142      await remember($, review)
143    }
144    await update($, current, () => review)
145    await setStatus($, { phase: 'ready' })
146
147    return review
148  } catch (error) {
149    if (token === loadToken) {
150      await setStatus($, { phase: 'error', message: error instanceof Error ? error.message : String(error) })
151    }
152
153    return undefined
154  }
155}
156
157async function openWeb($: EngineInterface, review: Review): Promise<string> {
158  const path = `${await home($)}/.claude/work-journal/${fileStem(review.window)}.html`
159  await $.fs.write(path, renderHtml(review))
160  const isMac = (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
161  await $.process.run([isMac ? 'open' : 'xdg-open', path]).catch(() => undefined)
162
163  return path
164}
165
166async function showWeb($: EngineInterface, review: Review): Promise<void> {
167  $.ui.toast(`Work journal saved to ${await openWeb($, review)}`)
168}
169
170function fail($: EngineInterface, error: unknown): void {
171  setStatus($, { phase: 'error', message: error instanceof Error ? error.message : String(error) }).catch(() => undefined)
172}
173
174function show($: EngineInterface, window: JournalWindow, isForced: boolean, isWeb: boolean): void {
175  $.clock.after(0, () => {
176    load($, window, isForced)
177      .then(review => (isWeb && review !== undefined ? showWeb($, review) : undefined))
178      .catch(error => fail($, error))
179  })
180}
181
182function openPage($: EngineInterface, review: Review): void {
183  $.clock.after(0, () => {
184    showWeb($, review).catch(error => fail($, error))
185  })
186}
187
188export const register: Register = (on, options) => {
189  settings.excluded = String(options.excludeProjects ?? '')
190    .split(',')
191    .map(part => part.trim())
192    .filter(part => part !== '')
193  settings.model = String(options.model ?? 'opus') || 'opus'
194
195  on('session.start', async ($, e, next) => {
196    await $.command
197      .register({
198        name: 'journal',
199        description: 'Review your work: today, a week or a month, summarized from your prompts',
200        argumentHint: '[today|yesterday|week|last week|month|last month|YYYY-MM-DD] [web]',
201      })
202      .catch(() => $.ui.toast('work-journal: could not register /journal'))
203
204    return next(e)
205  })
206
207  on('command.run', { command: 'journal' }, async ($, e) => {
208    const parsed = parseArgs(e.args, await today($))
209    if ('error' in parsed) {
210      return { text: parsed.error }
211    }
212
213    await update($, view, () => parsed.window)
214    await $.ui.open({ id: PANE, title: 'Work journal', focus: true, closeOnEscape: true })
215    show($, parsed.window, false, parsed.isWeb)
216
217    return { text: `Work journal: ${parsed.window.label}${parsed.isWeb ? ' (opening the webpage when it is ready)' : ''}.` }
218  })
219
220  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221    const { Box, Text, Button } = $.ui.resolve(e)
222    const window = await read($, view)
223    const review = await read($, current)
224    const state = await read($, status)
225    const width = Math.max(30, e.props.bodyColumns)
226
227    const go = (next: JournalWindow, isForced = false) => () => show($, next, isForced, false)
228    const anchor = window?.start ?? (await today($))
229    const controls = (
230      <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
231        <Button key="day" hotkey="d" plain label="Day" onPress={go(windowFor('day', anchor))} />
232        <Button key="week" hotkey="w" plain label="Week" onPress={go(windowFor('week', anchor))} />
233        <Button key="month" hotkey="m" plain label="Month" onPress={go(windowFor('month', anchor))} />
234        {window !== null ? <Button key="prev" hotkey="p" plain label="◀ Prev" onPress={go(shiftWindow(window, -1))} /> : null}
235        {window !== null ? <Button key="next" hotkey="n" plain label="Next ▶" onPress={go(shiftWindow(window, 1))} /> : null}
236        {window !== null ? <Button key="refresh" hotkey="r" plain label="Refresh" onPress={go(window, true)} /> : null}
237        {review !== null ? (
238          <Button
239            key="web"
240            hotkey="o"
241            plain
242            label="Open as webpage"
243            onPress={() => openPage($, review)}
244          />
245        ) : null}
246      </Box>
247    )
248
249    if (window === null) {
250      return (
251        <Box flexDirection="column">
252          <Text>Type /journal, /journal week or /journal month.</Text>
253          {controls}
254        </Box>
255      )
256    }
257
258    const header = (
259      <Box flexDirection="column">
260        <Text>
261          <Text bold>📓 Work journal</Text>
262          <Text dimColor> · {KIND_TITLE[window.kind]} review</Text>
263        </Text>
264        <Text bold color="claude">
265          {window.label}
266        </Text>
267      </Box>
268    )
269
270    if (state.phase === 'loading' || state.phase === 'error' || review === null || review.window.start !== window.start || review.window.kind !== window.kind) {
271      return (
272        <Box flexDirection="column">
273          {header}
274          <Box marginTop={1}>
275            {state.phase === 'error' ? (
276              <Text color="error">Couldn't build this review: {state.message ?? 'unknown error'}</Text>
277            ) : (
278              <Text dimColor>⋯ Reading your prompts and summarizing them. A month can take a minute.</Text>
279            )}
280          </Box>
281          {controls}
282        </Box>
283      )
284    }
285
286    const peak = Math.max(1, ...review.activity.map(bucket => bucket.count))
287    const barRoom = Math.max(8, Math.min(40, width - 14))
288    const chart =
289      review.window.kind === 'week' ? (
290        <Box flexDirection="column">
291          {review.activity.map(bucket => (
292            <Text>
293              <Text dimColor>{bucket.label.padEnd(4)}</Text>
294              <Text color="#6d8cf5">{'█'.repeat(Math.round((bucket.count / peak) * barRoom))}</Text>
295              <Text dimColor> {bucket.count > 0 ? bucket.count : ''}</Text>
296            </Text>
297          ))}
298        </Box>
299      ) : (
300        <Box flexDirection="column">
301          <Text color="#6d8cf5">{sparkline(review.activity)}</Text>
302          <Text dimColor>
303            {review.window.kind === 'day'
304              ? '0     6     12    18   23h'
305              : `1${' '.repeat(Math.max(1, review.activity.length - 1 - String(review.activity.length).length))}${review.activity.length}`}
306          </Text>
307        </Box>
308      )
309
310    return (
311      <Box flexDirection="column">
312        {header}
313        <Text dimColor>
314          {plural(review.prompts, 'prompt')} · {plural(review.projects.length + review.skippedProjects.length, 'project')} · ~{formatMinutes(review.activeMinutes)} active
315        </Text>
316        {review.headline === '' ? null : (
317          <Box marginTop={1}>
318            <Text italic>{review.headline}</Text>
319          </Box>
320        )}
321        <Box marginTop={1} flexDirection="column">
322          <Text dimColor>Prompts per {review.window.kind === 'day' ? 'hour' : 'day'}</Text>
323          {chart}
324        </Box>
325        {review.projects.map((project, index) => {
326          const color = PROJECT_COLORS[index % PROJECT_COLORS.length] ?? '#6d8cf5'
327
328          return (
329            <Box key={`project-${index}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginTop={1}>
330              <Text>
331                <Text color={color} bold>
332                  ● {project.name}
333                </Text>
334                <Text dimColor>
335                  {'  '}
336                  {plural(project.prompts, 'prompt')} · ~{formatMinutes(project.activeMinutes)}
337                </Text>
338              </Text>
339              {project.summary === '' ? null : <Text dimColor>{project.summary}</Text>}
340              {project.threads.map(thread => (
341                <Box flexDirection="column" marginTop={1}>
342                  <Text>
343                    <Text color={color}>▸ </Text>
344                    <Text bold>{thread.title}</Text>
345                    <Text dimColor> ({thread.prompts})</Text>
346                  </Text>
347                  {thread.goal === '' ? null : <Text dimColor>{`  ${thread.goal}`}</Text>}
348                  {thread.decisions.map(decision => (
349                    <Text>
350                      <Text color="success">{'  ✓ '}</Text>
351                      {decision}
352                    </Text>
353                  ))}
354                  {thread.open.map(item => (
355                    <Text>
356                      <Text color="warning">{'  ○ '}</Text>
357                      {item}
358                    </Text>
359                  ))}
360                </Box>
361              ))}
362            </Box>
363          )
364        })}
365        {review.skippedProjects.length === 0 ? null : (
366          <Text dimColor>Also active: {review.skippedProjects.map(project => `${project.name} (${project.prompts})`).join(', ')}</Text>
367        )}
368        {controls}
369      </Box>
370    )
371  })
372}
373
hooks/history.ts 122 lines
1import type { Bucket, JournalWindow } from '../types'
2import { daysIn, localDate, localHour, weekdayName } from './window'
3
4export type Entry = { text: string; at: number; project: string; session: string }
5
6export type ProjectEntries = { name: string; path: string; entries: Entry[] }
7
8const IDLE_GAP_MS = 30 * 60_000
9const LONE_PROMPT_MINUTES = 5
10const MAX_PROMPT_CHARS = 240
11
12export const filterScript = (from: number, to: number): string[] => [
13  'awk',
14  '-v',
15  `from=${from}`,
16  '-v',
17  `to=${to}`,
18  'match($0, /"timestamp":[0-9]+/) { t = substr($0, RSTART + 12, RLENGTH - 12) + 0; if (t >= from && t < to) print }',
19]
20
21export const parseHistory = (jsonl: string, excluded: readonly string[]): Entry[] =>
22  jsonl
23    .split('\n')
24    .flatMap(line => {
25      if (line.trim() === '') {
26        return []
27      }
28      try {
29        const row = JSON.parse(line) as { display?: unknown; timestamp?: unknown; project?: unknown; sessionId?: unknown }
30        const text = typeof row.display === 'string' ? row.display.trim() : ''
31        const project = typeof row.project === 'string' ? row.project : ''
32        if (text === '' || typeof row.timestamp !== 'number' || excluded.some(part => project.includes(part))) {
33          return []
34        }
35
36        return [{ text, at: row.timestamp, project, session: typeof row.sessionId === 'string' ? row.sessionId : '' }]
37      } catch {
38        return []
39      }
40    })
41    .sort((a, b) => a.at - b.at)
42
43export const projectName = (path: string): string => path.split('/').filter(part => part !== '').pop() ?? (path || 'unknown')
44
45export const groupByProject = (entries: readonly Entry[]): ProjectEntries[] => {
46  const groups = new Map<string, Entry[]>()
47  for (const entry of entries) {
48    groups.set(entry.project, [...(groups.get(entry.project) ?? []), entry])
49  }
50
51  return [...groups.entries()]
52    .map(([path, list]) => ({ name: projectName(path), path, entries: list }))
53    .sort((a, b) => b.entries.length - a.entries.length)
54}
55
56export const activeMinutes = (entries: readonly Entry[]): number => {
57  let minutes = 0
58  let previous: number | undefined
59  for (const at of entries.map(entry => entry.at).sort((a, b) => a - b)) {
60    const gap = previous === undefined ? Infinity : at - previous
61    minutes += gap <= IDLE_GAP_MS ? gap / 60_000 : LONE_PROMPT_MINUTES
62    previous = at
63  }
64
65  return Math.round(minutes)
66}
67
68export const activity = (entries: readonly Entry[], window: JournalWindow, offsetMinutes: number): Bucket[] => {
69  if (window.kind === 'day') {
70    const hours = Array.from({ length: 24 }, (_, hour) => ({ label: `${hour}`, count: 0 }))
71    for (const entry of entries) {
72      const bucket = hours[localHour(entry.at, offsetMinutes)]
73      if (bucket !== undefined) {
74        bucket.count += 1
75      }
76    }
77
78    return hours
79  }
80
81  const days = daysIn(window).map(day => ({
82    day,
83    label: window.kind === 'week' ? weekdayName(day) : day.slice(8).replace(/^0/, ''),
84    count: 0,
85  }))
86  for (const entry of entries) {
87    const bucket = days.find(day => day.day === localDate(entry.at, offsetMinutes))
88    if (bucket !== undefined) {
89      bucket.count += 1
90    }
91  }
92
93  return days.map(({ label, count }) => ({ label, count }))
94}
95
96const isWorthSummarizing = (text: string): boolean => !text.startsWith('/') && !text.startsWith('!') && text.split(/\s+/).length >= 4
97
98export const promptsForModel = (entries: readonly Entry[], limit: number, offsetMinutes: number): string => {
99  const useful = entries.filter(entry => isWorthSummarizing(entry.text))
100  const step = useful.length / limit
101  const picked = useful.length <= limit ? useful : Array.from({ length: limit }, (_, index) => useful[Math.floor(index * step)]).filter(entry => entry !== undefined)
102
103  return picked
104    .map(entry => {
105      const stamp = `${localDate(entry.at, offsetMinutes)} ${String(localHour(entry.at, offsetMinutes)).padStart(2, '0')}h`
106      const text = entry.text.replace(/\s+/g, ' ')
107
108      return `[${stamp}] ${text.length > MAX_PROMPT_CHARS ? `${text.slice(0, MAX_PROMPT_CHARS)}…` : text}`
109    })
110    .join('\n')
111}
112
113export const formatMinutes = (minutes: number): string => {
114  if (minutes < 60) {
115    return `${minutes}m`
116  }
117  const hours = Math.floor(minutes / 60)
118  const rest = minutes % 60
119
120  return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
121}
122
hooks/html.ts 149 lines
1import type { Review } from '../types'
2import { formatMinutes } from './history'
3
4export const PROJECT_COLORS = ['#6d8cf5', '#3fae7a', '#e0953a', '#a072e8', '#2fa8c9', '#e25d7a', '#8a9a3a', '#c76b3a']
5
6export const escapeHtml = (text: string): string =>
7  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
8
9const plural = (count: number, word: string): string => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`
10
11const list = (items: readonly string[], className: string, mark: string): string =>
12  items.length === 0 ? '' : `<ul class="${className}">${items.map(item => `<li><span class="mark">${mark}</span>${escapeHtml(item)}</li>`).join('')}</ul>`
13
14export const renderHtml = (review: Review): string => {
15  const peak = Math.max(1, ...review.activity.map(bucket => bucket.count))
16  const generated = new Date(review.generatedAt).toISOString().slice(0, 16).replace('T', ' ')
17  const bars = review.activity
18    .map(
19      bucket => `<div class="bar" title="${escapeHtml(bucket.label)}: ${plural(bucket.count, 'prompt')}">
20          <div class="fill" style="height:${Math.round((bucket.count / peak) * 100)}%"></div>
21          <span>${escapeHtml(bucket.label)}</span>
22        </div>`,
23    )
24    .join('')
25
26  const projects = review.projects
27    .map((project, index) => {
28      const color = PROJECT_COLORS[index % PROJECT_COLORS.length]
29      const threads = project.threads
30        .map(
31          thread => `<details class="thread" open>
32            <summary><span class="title">${escapeHtml(thread.title)}</span><span class="count">${plural(thread.prompts, 'prompt')}</span></summary>
33            ${thread.goal === '' ? '' : `<p class="goal">${escapeHtml(thread.goal)}</p>`}
34            ${list(thread.decisions, 'decisions', '✓')}
35            ${list(thread.open, 'open', '○')}
36          </details>`,
37        )
38        .join('')
39
40      return `<section class="project" style="--accent:${color}">
41        <header>
42          <h2><span class="dot"></span>${escapeHtml(project.name)}</h2>
43          <p class="meta">${plural(project.prompts, 'prompt')} · ~${formatMinutes(project.activeMinutes)} active</p>
44        </header>
45        <p class="path">${escapeHtml(project.path)}</p>
46        ${project.summary === '' ? '' : `<p class="summary">${escapeHtml(project.summary)}</p>`}
47        ${threads}
48      </section>`
49    })
50    .join('')
51
52  const skipped =
53    review.skippedProjects.length === 0
54      ? ''
55      : `<p class="skipped">Also active: ${review.skippedProjects.map(project => `${escapeHtml(project.name)} (${project.prompts})`).join(', ')}</p>`
56
57  return `<!doctype html>
58<html lang="en">
59<head>
60<meta charset="utf-8">
61<meta name="viewport" content="width=device-width, initial-scale=1">
62<title>Work journal · ${escapeHtml(review.window.label)}</title>
63<style>
64  :root {
65    --bg: #f6f5f1; --card: #ffffff; --ink: #1d1d1b; --muted: #6b6a64; --line: #e4e2da;
66    --chip: #efede6; --good: #2f8f5b; --open: #b7791f;
67    color-scheme: light;
68  }
69  @media (prefers-color-scheme: dark) {
70    :root {
71      --bg: #15161a; --card: #1e2026; --ink: #ecebe6; --muted: #9a9993; --line: #2d3038;
72      --chip: #272a32; --good: #5cc48a; --open: #e2b154;
73      color-scheme: dark;
74    }
75  }
76  * { box-sizing: border-box; }
77  body {
78    margin: 0; background: var(--bg); color: var(--ink);
79    font: 15px/1.55 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
80    -webkit-font-smoothing: antialiased;
81  }
82  main { max-width: 980px; margin: 0 auto; padding: 48px 16px 64px; }
83  .eyebrow { margin: 0; color: var(--muted); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
84  h1 { margin: 6px 0 4px; font-size: clamp(28px, 4vw, 40px); line-height: 1.15; letter-spacing: -.02em; }
85  .headline { margin: 0 0 28px; color: var(--muted); font-size: 17px; max-width: 60ch; }
86  .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
87  .stat { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
88  .stat b { display: block; font-size: 26px; letter-spacing: -.02em; }
89  .stat span { color: var(--muted); font-size: 13px; }
90  .chart { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 16px 10px; margin-bottom: 28px; }
91  .chart h3 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; }
92  .bars { display: flex; align-items: flex-end; gap: 4px; height: 120px; }
93  .bar { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; min-width: 0; }
94  .fill { width: 100%; max-width: 34px; min-height: 2px; border-radius: 5px 5px 2px 2px; background: linear-gradient(180deg, #8aa2f7, #5b77e6); }
95  .bar span { margin-top: 6px; font-size: 11px; color: var(--muted); white-space: nowrap; }
96  .project { background: var(--card); border: 1px solid var(--line); border-top: 4px solid var(--accent); border-radius: 14px; padding: 20px 22px; margin-bottom: 18px; }
97  .project header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
98  .project h2 { margin: 0; font-size: 20px; display: flex; align-items: center; gap: 10px; }
99  .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); display: inline-block; }
100  .meta { margin: 0; color: var(--muted); font-size: 13px; }
101  .path { margin: 2px 0 10px; color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
102  .summary { margin: 0 0 14px; }
103  .thread { border-top: 1px solid var(--line); padding: 10px 0 4px; }
104  .thread summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; gap: 12px; font-weight: 600; }
105  .thread summary::-webkit-details-marker { display: none; }
106  .thread summary::before { content: "▸"; color: var(--accent); margin-right: 8px; transition: transform .15s; display: inline-block; }
107  .thread[open] summary::before { transform: rotate(90deg); }
108  .title { flex: 1; }
109  .count { color: var(--muted); font-weight: 400; font-size: 13px; white-space: nowrap; background: var(--chip); padding: 1px 8px; border-radius: 999px; }
110  .goal { margin: 6px 0 6px 20px; color: var(--muted); }
111  .decisions, .open { list-style: none; margin: 4px 0 6px 20px; padding: 0; }
112  .decisions li, .open li { margin: 3px 0; padding-left: 22px; position: relative; }
113  .mark { position: absolute; left: 0; font-weight: 700; }
114  .decisions .mark { color: var(--good); }
115  .open .mark { color: var(--open); }
116  .skipped, footer { color: var(--muted); font-size: 13px; }
117  footer { margin-top: 32px; border-top: 1px solid var(--line); padding-top: 14px; }
118  @media print {
119    body { background: #fff; color: #000; }
120    main { padding: 0; }
121    .project, .stat, .chart { break-inside: avoid; box-shadow: none; }
122    .thread summary::before { display: none; }
123  }
124</style>
125</head>
126<body>
127<main>
128  <p class="eyebrow">Work journal · ${escapeHtml(review.window.kind === 'day' ? 'Daily' : review.window.kind === 'week' ? 'Weekly' : 'Monthly')} review</p>
129  <h1>${escapeHtml(review.window.label)}</h1>
130  <p class="headline">${escapeHtml(review.headline)}</p>
131  <div class="stats">
132    <div class="stat"><b>${review.prompts.toLocaleString('en-US')}</b><span>prompts</span></div>
133    <div class="stat"><b>${review.projects.length + review.skippedProjects.length}</b><span>projects</span></div>
134    <div class="stat"><b>~${formatMinutes(review.activeMinutes)}</b><span>active</span></div>
135    <div class="stat"><b>${review.projects.reduce((sum, project) => sum + project.threads.length, 0)}</b><span>threads</span></div>
136  </div>
137  <div class="chart">
138    <h3>Prompts per ${review.window.kind === 'day' ? 'hour' : 'day'}</h3>
139    <div class="bars">${bars}</div>
140  </div>
141  ${projects}
142  ${skipped}
143  <footer>Summarized from your Claude Code prompt history · generated ${generated} UTC · work-journal</footer>
144</main>
145</body>
146</html>
147`
148}
149
hooks/summarize.ts 89 lines
1import type { JournalWindow, ProjectReview, Thread } from '../types'
2import type { ProjectEntries } from './history'
3import { activeMinutes, promptsForModel } from './history'
4
5const PROMPT_LIMIT: Record<JournalWindow['kind'], number> = { day: 150, week: 250, month: 350 }
6const THREAD_LIMIT: Record<JournalWindow['kind'], number> = { day: 5, week: 6, month: 8 }
7
8const SYSTEM = `You write a developer's work journal from the prompts they typed to an AI coding assistant in one project.
9Group the prompts into threads: distinct pieces of work (a feature, a bug, a refactor, a question being researched). Name each thread in plain language, as a person would in a standup ("Material creation wizard", "Proofread mod for Claude Code"), never as a prompt or a commit prefix.
10For each thread give:
11- goal: one sentence, what they set out to do
12- decisions: choices they clearly made (a name picked, an approach chosen, scope cut). Only what the prompts state. May be empty
13- open: things they asked for that show no sign of being finished or revisited, or questions left hanging. May be empty
14- prompts: how many of the listed prompts belong to it
15Also write summary: one sentence on the project's work in this window.
16Write in the past tense, plainly, without praise or judgement. Never invent work the prompts don't show. Ignore chit-chat and approvals ("yes", "go ahead").
17Reply with JSON only, no prose and no code fence:
18{"summary": "...", "threads": [{"title": "...", "goal": "...", "decisions": ["..."], "open": ["..."], "prompts": 0}]}`
19
20const strings = (value: unknown): string[] =>
21  Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map(item => item.trim()) : []
22
23const repair = (json: string): string =>
24  json
25    .replace(/,\s*([}\]])/g, '$1')
26    .replace(/"(?:[^"\\]|\\.)*"/g, quoted => quoted.replace(/\n/g, '\\n').replace(/\t/g, '\\t'))
27
28const parseJson = (json: string): unknown => {
29  try {
30    return JSON.parse(json)
31  } catch {
32    return JSON.parse(repair(json))
33  }
34}
35
36export const parseReply = (text: string): { summary: string; threads: Thread[] } | null => {
37  const json = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
38  try {
39    const reply = parseJson(json) as { summary?: unknown; threads?: unknown }
40    const threads = (Array.isArray(reply.threads) ? reply.threads : []).flatMap((raw: unknown): Thread[] => {
41      const thread = (raw ?? {}) as Record<string, unknown>
42      if (typeof thread.title !== 'string' || thread.title.trim() === '') {
43        return []
44      }
45
46      return [
47        {
48          title: thread.title.trim(),
49          goal: typeof thread.goal === 'string' ? thread.goal.trim() : '',
50          decisions: strings(thread.decisions),
51          open: strings(thread.open),
52          prompts: typeof thread.prompts === 'number' ? Math.max(0, Math.round(thread.prompts)) : 0,
53        },
54      ]
55    })
56
57    return { summary: typeof reply.summary === 'string' ? reply.summary.trim() : '', threads }
58  } catch {
59    return null
60  }
61}
62
63export const emptyProject = (project: ProjectEntries): ProjectReview => ({
64  name: project.name,
65  path: project.path,
66  prompts: project.entries.length,
67  activeMinutes: activeMinutes(project.entries),
68  summary: '',
69  threads: [],
70})
71
72export const summaryRequest = (
73  project: ProjectEntries,
74  window: JournalWindow,
75  offsetMinutes: number,
76): { system: string; prompt: string } | null => {
77  const prompts = promptsForModel(project.entries, PROMPT_LIMIT[window.kind], offsetMinutes)
78  if (prompts === '') {
79    return null
80  }
81
82  return {
83    system: SYSTEM,
84    prompt: `Project: ${project.name} (${project.path})\nWindow: ${window.label}\nAt most ${THREAD_LIMIT[window.kind]} threads, the largest first.\n\nPrompts, oldest first:\n${prompts}`,
85  }
86}
87
88export const threadLimit = (window: JournalWindow): number => THREAD_LIMIT[window.kind]
89
hooks/window.ts 129 lines
1import type { JournalWindow, WindowKind } from '../types'
2
3const DAY_MS = 86_400_000
4const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
5const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
6const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
7
8const parts = (date: string): [number, number, number] => {
9  const [y, m, d] = date.split('-').map(Number)
10
11  return [y ?? 1970, m ?? 1, d ?? 1]
12}
13
14const utc = (date: string): number => {
15  const [y, m, d] = parts(date)
16
17  return Date.UTC(y, m - 1, d)
18}
19
20const format = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
21
22export const addDays = (date: string, days: number): string => format(utc(date) + days * DAY_MS)
23
24export const weekdayIndex = (date: string): number => (new Date(utc(date)).getUTCDay() + 6) % 7
25
26export const weekdayName = (date: string): string => WEEKDAYS[weekdayIndex(date)] ?? ''
27
28export const localDate = (ms: number, offsetMinutes: number): string => format(ms + offsetMinutes * 60_000)
29
30export const localHour = (ms: number, offsetMinutes: number): number => new Date(ms + offsetMinutes * 60_000).getUTCHours()
31
32const short = (date: string): string => {
33  const [, m, d] = parts(date)
34
35  return `${MONTHS[m - 1]} ${d}`
36}
37
38const labelFor = (kind: WindowKind, start: string, end: string): string => {
39  const [y, m] = parts(start)
40  if (kind === 'day') {
41    return `${weekdayName(start)} ${short(start)}, ${y}`
42  }
43  if (kind === 'week') {
44    return `Week of ${short(start)} – ${short(end)}, ${parts(end)[0]}`
45  }
46
47  return `${MONTH_NAMES[m - 1]} ${y}`
48}
49
50export const windowFor = (kind: WindowKind, anchor: string): JournalWindow => {
51  let start = anchor
52  let end = anchor
53  if (kind === 'week') {
54    start = addDays(anchor, -weekdayIndex(anchor))
55    end = addDays(start, 6)
56  }
57  if (kind === 'month') {
58    const [y, m] = parts(anchor)
59    start = format(Date.UTC(y, m - 1, 1))
60    end = format(Date.UTC(y, m, 0))
61  }
62
63  return { kind, start, end, label: labelFor(kind, start, end) }
64}
65
66export const shiftWindow = (window: JournalWindow, direction: 1 | -1): JournalWindow => {
67  if (window.kind === 'day') {
68    return windowFor('day', addDays(window.start, direction))
69  }
70  if (window.kind === 'week') {
71    return windowFor('week', addDays(window.start, 7 * direction))
72  }
73
74  return windowFor('month', direction === 1 ? addDays(window.end, 1) : addDays(window.start, -1))
75}
76
77export const bounds = (window: JournalWindow, offsetMinutes: number): { from: number; to: number } => ({
78  from: utc(window.start) - offsetMinutes * 60_000,
79  to: utc(addDays(window.end, 1)) - offsetMinutes * 60_000,
80})
81
82export const isComplete = (window: JournalWindow, today: string): boolean => window.end < today
83
84export const daysIn = (window: JournalWindow): string[] => {
85  const days: string[] = []
86  for (let day = window.start; day <= window.end; day = addDays(day, 1)) {
87    days.push(day)
88  }
89
90  return days
91}
92
93export const fileStem = (window: JournalWindow): string => `${window.kind}-${window.start}`
94
95export type ParsedArgs = { window: JournalWindow; isWeb: boolean } | { error: string }
96
97export const parseArgs = (args: string, today: string): ParsedArgs => {
98  const words = args.toLowerCase().trim().split(/\s+/).filter(word => word !== '')
99  const isWeb = words.includes('web')
100  const rest = words.filter(word => word !== 'web' && word !== 'this').join(' ')
101
102  const named: Record<string, JournalWindow> = {
103    '': windowFor('day', today),
104    today: windowFor('day', today),
105    day: windowFor('day', today),
106    daily: windowFor('day', today),
107    yesterday: windowFor('day', addDays(today, -1)),
108    week: windowFor('week', today),
109    weekly: windowFor('week', today),
110    'last week': shiftWindow(windowFor('week', today), -1),
111    month: windowFor('month', today),
112    monthly: windowFor('month', today),
113    'last month': shiftWindow(windowFor('month', today), -1),
114  }
115
116  const window = named[rest]
117  if (window !== undefined) {
118    return { window, isWeb }
119  }
120  if (/^\d{4}-\d{2}-\d{2}$/.test(rest) && !Number.isNaN(utc(rest))) {
121    return { window: windowFor('day', rest), isWeb }
122  }
123  if (/^\d{4}-\d{2}$/.test(rest)) {
124    return { window: windowFor('month', `${rest}-01`), isWeb }
125  }
126
127  return { error: `Unknown window "${rest}". Try: today, yesterday, week, last week, month, last month, a date (2026-10-05) or a month (2026-09), plus "web".` }
128}
129
types/index.d.ts 47 lines
1export type WindowKind = 'day' | 'week' | 'month'
2
3export type JournalWindow = {
4  kind: WindowKind
5  start: string
6  end: string
7  label: string
8}
9
10export type Thread = {
11  title: string
12  goal: string
13  decisions: string[]
14  open: string[]
15  prompts: number
16}
17
18export type ProjectReview = {
19  name: string
20  path: string
21  prompts: number
22  activeMinutes: number
23  summary: string
24  threads: Thread[]
25}
26
27export type Bucket = { label: string; count: number }
28
29export type Review = {
30  window: JournalWindow
31  generatedAt: number
32  prompts: number
33  activeMinutes: number
34  headline: string
35  activity: Bucket[]
36  projects: ProjectReview[]
37  skippedProjects: { name: string; prompts: number }[]
38}
39
40export type Status = { phase: 'idle' | 'loading' | 'ready' | 'error'; message?: string }
41
42declare module 'claude-code' {
43  interface PluginState {
44    'work-journal': { view: JournalWindow | null; review: Review | null; status: Status }
45  }
46}
47