Keeps a short log of what you asked Claude to do and which files changed, across sessions, and writes your standup update with /standup.

Never write a standup from memory again. Standup keeps a tiny log of what you asked Claude to do and which files changed — across every session on your machine — and /standup turns it into your update.
/standup — today · /standup yesterday · /standup week/standup (Haiku by default)./plugin marketplace add claudemodz/mods
/plugin install standup@claudemodz-mods
/config)haiku)Reads your prompts and which files Claude edits, reads .git/HEAD for the branch name, stores the log with $.store, and calls the model when you run /standup. It does not run commands or use the network beyond that model call.
hooks/register.ts 131 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import {
4 addRecord,
5 baseName,
6 branchFromHead,
7 gitdirOf,
8 isRange,
9 parentsOf,
10 readRecords,
11 recordsIn,
12 relativeTo,
13 standupPrompt,
14 STANDUP_SYSTEM,
15 type WorkRecord,
16} from './records.js'
17
18const STORE_KEY = 'records'
19const PROMPT_CHARS = 200
20const MAX_FILES = 20
21
22/** Prompts the person typed: in the terminal or desktop app, or through Remote Control. */
23const USER_ORIGINS: readonly string[] = ['composer', 'bridge']
24
25export function register(on: On, options: PluginOptions): void {
26 const model = String(options.model ?? 'haiku')
27 const retentionDays = Math.max(1, Number(options.retentionDays ?? 14))
28 let prompt: string | undefined
29 let queued: string | undefined
30 const files = new Set<string>()
31
32 on('session.start', async ($, e, next) => {
33 await $.command.register({
34 name: 'standup',
35 description: 'Write a standup update from what you worked on',
36 argumentHint: '[today|yesterday|week]',
37 })
38 return next(e)
39 })
40
41 on('prompt.submit', ($, e, next) => {
42 const text = e.text.trim()
43 const isUsers = USER_ORIGINS.includes(e.origin.kind) && text !== '' && !text.startsWith('/')
44 const candidate = isUsers ? text : undefined
45 if (e.turnId !== undefined) {
46 // Typed while a turn ran: it gets its own turn after this one.
47 if (candidate !== undefined) queued = candidate
48 return next(e)
49 }
50 prompt = candidate
51 files.clear()
52 return next(e)
53 })
54
55 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
56 const result = await next(e)
57 const path =
58 'file_path' in e && typeof e.file_path === 'string'
59 ? e.file_path
60 : 'notebook_path' in e && typeof e.notebook_path === 'string'
61 ? e.notebook_path
62 : undefined
63 if (e.agentId === undefined && result.deny === undefined && path !== undefined) files.add(path)
64 return result
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 const result = await next(e)
69 if (e.agentId !== undefined) return result
70 const finished = prompt
71 const edited = [...files]
72 prompt = queued
73 queued = undefined
74 files.clear()
75 if (e.reason === 'answer' && finished !== undefined) {
76 const entry = await recordOf($, finished, edited).catch(() => undefined)
77 if (entry) {
78 const records = readRecords(await $.store.get(STORE_KEY))
79 await $.store.set(STORE_KEY, addRecord(records, entry, retentionDays))
80 }
81 }
82 return result
83 })
84
85 on('command.run', { command: 'standup' }, async ($, e) => {
86 const range = e.args.trim() || 'today'
87 if (!isRange(range)) return { text: 'Usage: /standup [today|yesterday|week]' }
88 const picked = recordsIn(readRecords(await $.store.get(STORE_KEY)), range, await $.clock.now())
89 if (picked.length === 0) return { text: `Nothing recorded for ${range}.` }
90 const reply = await $.model.complete({
91 model,
92 system: STANDUP_SYSTEM,
93 prompt: standupPrompt(picked, range),
94 maxTokens: 800,
95 })
96 return { text: reply.isAnswered ? reply.text : `Couldn't write the standup (${reply.reason}).` }
97 })
98}
99
100async function recordOf($: EngineInterface, prompt: string, edited: readonly string[]): Promise<WorkRecord> {
101 const [at, cwd, repo] = await Promise.all([$.clock.now(), $.session.cwd(), $.session.repo()])
102 const git = repo === null ? undefined : await gitOf($, cwd)
103 const root = git?.root ?? repo?.root ?? cwd
104 return {
105 at,
106 repo: baseName(repo?.root ?? root),
107 branch: branchFromHead(git?.head ?? ''),
108 prompt: prompt.slice(0, PROMPT_CHARS),
109 files: edited.map(path => relativeTo(path, root)).slice(0, MAX_FILES),
110 }
111}
112
113/** The working tree holding `cwd` and its HEAD, following a worktree's `.git` file. */
114async function gitOf($: EngineInterface, cwd: string): Promise<{ root: string; head: string } | undefined> {
115 for (const dir of parentsOf(cwd)) {
116 const head = await readText($, `${dir}/.git/HEAD`)
117 if (head !== undefined) return { root: dir, head }
118 const pointer = await readText($, `${dir}/.git`)
119 const gitdir = pointer === undefined ? null : gitdirOf(pointer, dir)
120 if (gitdir !== null) return { root: dir, head: (await readText($, `${gitdir}/HEAD`)) ?? '' }
121 }
122 return undefined
123}
124
125async function readText($: EngineInterface, path: string): Promise<string | undefined> {
126 return $.fs.read(path).then(
127 text => (typeof text === 'string' ? text : undefined),
128 () => undefined,
129 )
130}
131hooks/records.ts 113 lines1export type WorkRecord = {
2 readonly at: number
3 readonly repo: string
4 readonly branch: string
5 readonly prompt: string
6 readonly files: readonly string[]
7}
8
9export type Range = 'today' | 'yesterday' | 'week'
10
11const DAY_MS = 86_400_000
12const MAX_RECORDS = 3000
13
14export const STANDUP_SYSTEM =
15 'You write short standup updates for a software engineer from their work log. ' +
16 'Use only what the log shows; never invent work. Output markdown with a "Done" section ' +
17 'grouped by repository, at most 8 bullets in total, each a plain past-tense phrase. ' +
18 'Merge log lines that describe the same piece of work.'
19
20export function isRange(value: string): value is Range {
21 return value === 'today' || value === 'yesterday' || value === 'week'
22}
23
24export function readRecords(stored: unknown): WorkRecord[] {
25 return Array.isArray(stored) ? stored.filter(isWorkRecord) : []
26}
27
28function isWorkRecord(value: unknown): value is WorkRecord {
29 if (typeof value !== 'object' || value === null) return false
30 const r = value as Record<string, unknown>
31 return (
32 typeof r.at === 'number' &&
33 typeof r.repo === 'string' &&
34 typeof r.branch === 'string' &&
35 typeof r.prompt === 'string' &&
36 Array.isArray(r.files) &&
37 r.files.every(f => typeof f === 'string')
38 )
39}
40
41export function addRecord(
42 records: readonly WorkRecord[],
43 record: WorkRecord,
44 retentionDays: number,
45): WorkRecord[] {
46 const cutoff = record.at - retentionDays * DAY_MS
47 return [...records.filter(r => r.at >= cutoff), record].slice(-MAX_RECORDS)
48}
49
50function startOfDay(ms: number): number {
51 const day = new Date(ms)
52 day.setHours(0, 0, 0, 0)
53 return day.getTime()
54}
55
56export function recordsIn(records: readonly WorkRecord[], range: Range, now: number): WorkRecord[] {
57 const today = startOfDay(now)
58 const from = range === 'today' ? today : range === 'yesterday' ? startOfDay(today - 1) : startOfDay(today - 6 * DAY_MS)
59 const to = range === 'yesterday' ? today : Number.POSITIVE_INFINITY
60 return records.filter(r => r.at >= from && r.at < to)
61}
62
63export function branchFromHead(head: string): string {
64 const ref = head.match(/^ref: refs\/heads\/(.+)$/m)?.[1]?.trim()
65 if (ref) return ref
66 return /^[0-9a-f]{40}\s*$/.test(head) ? 'detached' : 'unknown'
67}
68
69/** A directory and each of its parents, nearest first, ending at `/`. */
70export function parentsOf(path: string): string[] {
71 const parts = path.split('/').filter(Boolean)
72 const dirs: string[] = []
73 for (let i = parts.length; i > 0; i--) dirs.push('/' + parts.slice(0, i).join('/'))
74 return [...dirs, '/']
75}
76
77/** The git directory a worktree's `.git` file points at, or null when it isn't one. */
78export function gitdirOf(content: string, dir: string): string | null {
79 const target = content.match(/^gitdir:\s*(.+)$/m)?.[1]?.trim()
80 if (!target) return null
81 return target.startsWith('/') ? target : `${dir.replace(/\/+$/, '')}/${target}`
82}
83
84export function baseName(path: string): string {
85 return path.replace(/\/+$/, '').split('/').pop() ?? path
86}
87
88export function relativeTo(path: string, root: string): string {
89 const prefix = root.replace(/\/+$/, '') + '/'
90 return path.startsWith(prefix) ? path.slice(prefix.length) : path
91}
92
93function clockTime(ms: number): string {
94 const d = new Date(ms)
95 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
96}
97
98export function standupPrompt(records: readonly WorkRecord[], range: Range): string {
99 const groups = new Map<string, WorkRecord[]>()
100 for (const r of records) {
101 const key = `${r.repo} (${r.branch})`
102 groups.set(key, [...(groups.get(key) ?? []), r])
103 }
104 const sections = [...groups].map(([key, items]) => {
105 const lines = items.map(r => {
106 const files = r.files.length > 0 ? ` — files: ${r.files.join(', ')}` : ''
107 return `- ${clockTime(r.at)} ${r.prompt}${files}`
108 })
109 return `## ${key}\n${lines.join('\n')}`
110 })
111 return `Work log for ${range}:\n\n${sections.join('\n\n')}\n\nWrite my standup update.`
112}
113