Writes a markdown log of every prompt, changed file and command to .claude/journal, with a /standup summary.

hooks/register.tsx 266 lines1// Session Journal: after every main-loop turn, appends what you asked, which
2// files changed and which commands ran to .claude/journal/YYYY-MM-DD.md.
3// /standup turns a day's journal into a short "what I did" list.
4//
5// Set SESSION_JOURNAL_DIR to write somewhere else (absolute, or relative to
6// the project).
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { JournalFile, JournalTurn } from '../types'
12
13const current = atom({ plugin: 'session-journal', key: 'current' } as const, null)
14const cwd = atom({ plugin: 'session-journal', key: 'cwd' } as const, '')
15const dir = atom({ plugin: 'session-journal', key: 'dir' } as const, '')
16
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
18const PROMPT_LIMIT = 1200
19const REPLY_LIMIT = 300
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 const result = await next(e)
24 const root = e.cwd
25 const custom = await $.env.get('SESSION_JOURNAL_DIR')
26 await update($, cwd, () => root)
27 await update($, dir, () => resolveDir(root, custom))
28 await registerCommand($, {
29 name: 'standup',
30 description: "Summarize today's session journal (or: /standup yesterday)",
31 argumentHint: '[yesterday|YYYY-MM-DD]',
32 })
33 await registerCommand($, {
34 name: 'journal',
35 description: "Show where today's session journal is written",
36 })
37 return result
38 })
39
40 on('turn.start', async ($, e, next) => {
41 const startedAt = await $.clock.now()
42 const turn: JournalTurn = { prompt: e.text, startedAt, files: [], commands: [] }
43 await update($, current, () => turn)
44 return next(e)
45 })
46
47 on('tool.call', async ($, e, next) => {
48 const tool = String(e.tool)
49 const input = e as unknown as Record<string, unknown>
50 const isEdit = EDIT_TOOLS.has(tool)
51 const isBash = tool === 'Bash'
52 if (!isEdit && !isBash) return next(e)
53 if ((await read($, current)) === null) return next(e)
54
55 const path = isEdit ? pathOf(input) : undefined
56 const existed = path !== undefined && tool === 'Write' ? await exists($, path) : true
57 const ran = await next(e)
58 const ok = ran.deny === undefined && ran.isError !== true
59
60 if (isEdit && path !== undefined && ok) {
61 const root = await read($, cwd)
62 const shown = relative(path, root)
63 await update($, current, turn => {
64 if (turn === null) return turn
65 if (turn.files.some(f => f.path === shown)) return turn
66 const file: JournalFile = { path: shown, kind: existed ? 'edited' : 'created' }
67 return { ...turn, files: [...turn.files, file] }
68 })
69 }
70
71 if (isBash && ran.deny === undefined) {
72 const command = oneLine(String(input.command ?? ''), 200)
73 const byAgent = (input as { agentId?: string }).agentId !== undefined
74 await update($, current, turn =>
75 turn === null ? turn : { ...turn, commands: [...turn.commands, { command, ok, byAgent }] },
76 )
77 }
78 return ran
79 })
80
81 on('turn.complete', async ($, e, next) => {
82 const result = await next(e)
83 if (e.agentId !== undefined) return result
84 const turn = await read($, current)
85 if (turn === null) return result
86 await update($, current, () => null)
87
88 try {
89 const now = await $.clock.now()
90 const file = `${await read($, dir)}/${day(now)}.md`
91 const project = basename(await read($, cwd))
92 const before = (await exists($, file)) ? await $.fs.read(file) : header(project, now)
93 const outcome = e.isAborted ? 'interrupted' : e.reason === 'answer' ? 'answered' : e.reason
94 const entry = formatEntry(turn, {
95 at: turn.startedAt,
96 outcome,
97 durationMs: e.durationMs,
98 reply: e.answer,
99 })
100 await $.fs.write(file, before.replace(/\s*$/, '\n\n') + entry)
101 } catch (error) {
102 $.ui.log(`session-journal: could not write the journal (${String(error)})`, { to: 'debug' })
103 }
104 return result
105 })
106
107 on('command.run', { command: 'journal' }, async $ => {
108 const file = `${await read($, dir)}/${day(await $.clock.now())}.md`
109 if (!(await exists($, file))) return { text: `No journal yet today. It will be written to ${file}` }
110 const entries = parseEntries(await $.fs.read(file))
111 return { text: `Today's journal: ${file} (${entries.length} ${plural(entries.length, 'prompt')})` }
112 })
113
114 on('command.run', { command: 'standup' }, async ($, e) => {
115 const now = await $.clock.now()
116 const which = e.args.trim()
117 const date = which === 'yesterday' ? day(now - 86_400_000) : /^\d{4}-\d{2}-\d{2}$/.test(which) ? which : day(now)
118 const file = `${await read($, dir)}/${date}.md`
119 if (!(await exists($, file))) return { text: `No journal for ${date} (${file}).` }
120 return { text: standup(date, parseEntries(await $.fs.read(file)), file) }
121 })
122}
123
124// ---------- formatting ----------
125
126type EntryFacts = { at: number; outcome: string; durationMs: number; reply: string }
127
128export function formatEntry(turn: JournalTurn, facts: EntryFacts): string {
129 const prompt = turn.prompt.trim() === '' ? '(continued without a new prompt)' : turn.prompt.trim()
130 const title = oneLine(prompt, 80)
131 const lines: string[] = [`## ${clock(facts.at)} · ${title}`, '']
132 lines.push(...clip(prompt, PROMPT_LIMIT).split('\n').map(l => `> ${l}`), '')
133
134 if (turn.files.length > 0) {
135 lines.push(`**Files** (${turn.files.length})`)
136 for (const f of turn.files) lines.push(`- \`${f.path}\` (${f.kind})`)
137 lines.push('')
138 }
139 if (turn.commands.length > 0) {
140 lines.push(`**Commands** (${turn.commands.length})`)
141 for (const c of turn.commands) {
142 lines.push(`- ${c.ok ? '✓' : '✗'} \`${c.command.replace(/`/g, "'")}\`${c.byAgent ? ' (subagent)' : ''}`)
143 }
144 lines.push('')
145 }
146 lines.push(`**Outcome:** ${facts.outcome} in ${duration(facts.durationMs)}`)
147 const reply = oneLine(facts.reply, REPLY_LIMIT)
148 if (reply !== '') lines.push(`**Reply:** ${reply}`)
149 return lines.join('\n') + '\n'
150}
151
152function header(project: string, now: number): string {
153 return `# Session journal · ${project} · ${day(now)}\n`
154}
155
156type ParsedEntry = { time: string; title: string; files: string[]; failed: number }
157
158export function parseEntries(text: string): ParsedEntry[] {
159 const entries: ParsedEntry[] = []
160 let entry: ParsedEntry | undefined
161 for (const line of text.split('\n')) {
162 const head = /^## (\d\d:\d\d) · (.*)$/.exec(line)
163 if (head) {
164 entry = { time: head[1]!, title: head[2]!, files: [], failed: 0 }
165 entries.push(entry)
166 continue
167 }
168 if (!entry) continue
169 const file = /^- `(.+)` \((created|edited)\)$/.exec(line)
170 if (file) entry.files.push(file[1]!)
171 if (line.startsWith('- ✗ ')) entry.failed += 1
172 }
173 return entries
174}
175
176export function standup(date: string, entries: readonly ParsedEntry[], file: string): string {
177 if (entries.length === 0) return `The journal for ${date} has no entries yet (${file}).`
178 const files = [...new Set(entries.flatMap(e => e.files))]
179 const lines = [`Standup for ${date}: ${entries.length} ${plural(entries.length, 'prompt')}, ${files.length} ${plural(files.length, 'file')} changed`, '']
180 for (const e of entries) {
181 const extra = [
182 e.files.length > 0 ? `${e.files.length} ${plural(e.files.length, 'file')}` : '',
183 e.failed > 0 ? `${e.failed} failed ${plural(e.failed, 'command')}` : '',
184 ].filter(Boolean)
185 lines.push(`- ${e.time} ${e.title}${extra.length > 0 ? ` (${extra.join(', ')})` : ''}`)
186 }
187 if (files.length > 0) {
188 lines.push('', `Files: ${files.slice(0, 15).join(', ')}${files.length > 15 ? `, +${files.length - 15} more` : ''}`)
189 }
190 lines.push('', `Full journal: ${file}`)
191 return lines.join('\n')
192}
193
194// ---------- helpers ----------
195
196async function registerCommand($: EngineInterface, spec: Parameters<EngineInterface['command']['register']>[0]) {
197 try {
198 await $.command.register(spec)
199 } catch (error) {
200 $.ui.log(`session-journal: could not register /${spec.name} (${String(error)})`, { to: 'debug' })
201 }
202}
203
204async function exists($: EngineInterface, path: string): Promise<boolean> {
205 try {
206 return await $.fs.exists(path)
207 } catch {
208 return false
209 }
210}
211
212function resolveDir(root: string, custom: string | undefined): string {
213 const r = root.replace(/[\\/]+$/, '')
214 if (custom === undefined || custom.trim() === '') return `${r}/.claude/journal`
215 const c = custom.trim().replace(/[\\/]+$/, '')
216 return /^([a-zA-Z]:[\\/]|[\\/])/.test(c) ? c : `${r}/${c}`
217}
218
219function pathOf(input: Record<string, unknown>): string | undefined {
220 const p = input.file_path ?? input.notebook_path
221 return typeof p === 'string' && p.length > 0 ? p : undefined
222}
223
224function relative(path: string, root: string): string {
225 const p = path.replace(/\\/g, '/')
226 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
227 return r !== '' && p.toLowerCase().startsWith(r.toLowerCase() + '/') ? p.slice(r.length + 1) : p
228}
229
230function basename(path: string): string {
231 const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
232 return parts[parts.length - 1] ?? path
233}
234
235function oneLine(text: string, max: number): string {
236 const flat = text.replace(/\s+/g, ' ').trim()
237 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
238}
239
240function clip(text: string, max: number): string {
241 return text.length > max ? text.slice(0, max - 1) + '…' : text
242}
243
244const pad = (n: number) => String(n).padStart(2, '0')
245
246export function day(ms: number): string {
247 const d = new Date(ms)
248 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
249}
250
251function clock(ms: number): string {
252 const d = new Date(ms)
253 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
254}
255
256function duration(ms: number): string {
257 const s = Math.round(ms / 1000)
258 if (s < 60) return `${s}s`
259 const m = Math.floor(s / 60)
260 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
261}
262
263function plural(n: number, word: string): string {
264 return n === 1 ? word : `${word}s`
265}
266types/index.d.ts 20 lines1export type JournalFile = { path: string; kind: 'created' | 'edited' }
2export type JournalCommand = { command: string; ok: boolean; byAgent: boolean }
3
4export type JournalTurn = {
5 prompt: string
6 startedAt: number
7 files: JournalFile[]
8 commands: JournalCommand[]
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'session-journal': {
14 current: JournalTurn | null
15 cwd: string
16 dir: string
17 }
18 }
19}
20