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

<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>
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+.
Plugins built on function hooks: they change what Claude Code shows and does, live.
| Mod | What it does | Command | |
|---|---|---|---|
| ✍️ | proofread | Fixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out | /proofread |
| 📓 | work-journal | Daily, 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-tooling | Runs 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-msg | Writes 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 |
Instructions Claude loads when your request matches them. Ask in plain words.
| Skill | What it does | Try | |
|---|---|---|---|
| 📊 | commit-summary | Summarizes 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?" |
hooks/register.tsx 373 lines1import { 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}
373hooks/history.ts 122 lines1import 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}
122hooks/html.ts 149 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
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}
149hooks/summarize.ts 89 lines1import 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]
89hooks/window.ts 129 lines1import 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}
129types/index.d.ts 47 lines1export 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