Your standup, written for you: journals every Claude turn (prompt, files, commits) and turns yesterday and today into Yesterday / Today / Blockers with…

Your standup, written for you. standup journals every Claude turn and turns the last working day and today into Yesterday / Today / Blockers with one command.
> /standup
Friday
- [api] Fixed the login redirect loop; committed "Fix login redirect".
- [api] Added retry with backoff to the payments client and its tests.
- [web] Started the dark mode toggle in ui/theme.ts.
Today
- [web] Finished dark mode and wired it into settings; committed "Add dark mode".
Blockers
- [api] The flaky checkout test failed again after two attempts.
(/standup copy puts it on your clipboard)
You did the work, but at 9:58 you can't remember what it was. standup keeps a small journal as you work: the prompt you typed, the files Claude changed and the commits it made. /standup hands that journal to a fast model, which writes the standup.
Edit/Write/NotebookEdit calls, git commits (the subject comes from git's own output, or from -m and heredoc messages), and how long it took./standup writes the last working day (Friday on a Monday) and today, plus blockers inferred only from the journal: repeated attempts, failing tests, interrupted turns. It never invents work./standup raw [days] prints the journal itself, no model involved (the last 2 days by default, up to 14)./standup copy puts the last standup on your clipboard for Slack./standup clear wipes the journal./standup shows the raw journal instead./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install standup@awesome-claude-mods
Requires Claude Code 2.1.287 or later. The journal starts filling from the moment the mod is installed.
Set this in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
model | haiku | The model that writes the standup: an alias (haiku, sonnet) or a full model id. |
| Event / API | Why |
|---|---|
prompt.submit | Records the prompt you typed (your own prompts only; slash commands and plugin prompts are skipped) |
tool.call on Edit, Write, NotebookEdit, Bash | Records the files changed and git commit subjects of calls that succeeded |
turn.complete | Writes the turn's entry to $.store under its day and drops days older than 14 |
command.run | /standup, raw, copy, clear |
$.model.complete | One call per /standup (~600 output tokens at most) |
$.ui.copy | /standup copy |
claude plugin test mods/productivity/standup # 22 tests
sed -i, isn't in the journal./standup costs one small model call. /standup raw is free.hooks/register.ts 179 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 baseName,
5 commitSubject,
6 dayLabel,
7 dayOf,
8 DAY_PREFIX,
9 expiredKeys,
10 isGitCommit,
11 journalDays,
12 journalText,
13 relativeTo,
14 standupPrompt,
15 SYSTEM,
16 withEntry,
17 type Entry,
18} from './journal'
19
20// Prompts from these origins are the person's own words.
21const PERSON = new Set(['composer', 'bridge', 'sdk'])
22
23// What the running turn has done so far; reset when it completes.
24let prompt = ''
25let files: string[] = []
26let commits: string[] = []
27
28async function whereWeAre($: EngineInterface): Promise<{ project: string; root: string }> {
29 try {
30 const repo = await $.session.repo()
31 if (repo !== null) return { project: baseName(repo.root), root: repo.root }
32 } catch {
33 // Not a repository, or git is missing: the folder names the project.
34 }
35 const root = await $.session.root()
36 return { project: baseName(root), root }
37}
38
39async function readDay($: EngineInterface, day: string): Promise<Entry[]> {
40 const value = await $.store.get(DAY_PREFIX + day)
41 return Array.isArray(value) ? (value as Entry[]) : []
42}
43
44/** The newest `count` journal days, oldest first. */
45async function recentDays($: EngineInterface, count: number): Promise<{ day: string; entries: Entry[] }[]> {
46 const days = journalDays(await $.store.keys()).slice(0, count).reverse()
47 const out: { day: string; entries: Entry[] }[] = []
48 for (const day of days) {
49 const entries = await readDay($, day)
50 if (entries.length > 0) out.push({ day, entries })
51 }
52 return out
53}
54
55async function record($: EngineInterface, durationMs: number): Promise<void> {
56 const now = await $.clock.now()
57 const today = dayOf(now)
58 const { project, root } = await whereWeAre($)
59 const entry: Entry = {
60 at: now,
61 project,
62 prompt,
63 files: files.map(f => relativeTo(f, root)),
64 commits,
65 durationMs,
66 }
67 await $.store.set(DAY_PREFIX + today, withEntry(await readDay($, today), entry))
68 for (const key of expiredKeys(await $.store.keys(), today)) await $.store.delete(key)
69}
70
71export const register: Register = (on, options) => {
72 const model = String(options.model ?? 'haiku')
73
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'standup',
77 description: 'Write your standup from what you did with Claude: /standup [raw [days] | copy | clear]',
78 argumentHint: '[raw [days] | copy | clear]',
79 })
80 return next(e)
81 })
82
83 on('prompt.submit', async ($, e, next) => {
84 if (PERSON.has(e.origin.kind) && !e.text.trimStart().startsWith('/')) {
85 // A prompt typed into a running turn adds to it; a new turn starts fresh.
86 prompt = e.turnId !== undefined && prompt !== '' ? `${prompt} / ${e.text}` : e.text
87 if (e.turnId === undefined) {
88 files = []
89 commits = []
90 }
91 }
92 return next(e)
93 })
94
95 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
96 const out = await next(e)
97 if (out.deny !== undefined || out.isError === true) return out
98 if (e.tool === 'Edit' || e.tool === 'Write') files.push(e.file_path)
99 else if (e.tool === 'NotebookEdit') files.push(e.notebook_path)
100 else if (e.tool === 'Bash' && isGitCommit(e.command)) {
101 const subject = commitSubject(e.command, out.text ?? '')
102 if (subject !== undefined) commits.push(subject)
103 }
104 return out
105 })
106
107 on('turn.complete', async ($, e, next) => {
108 const out = await next(e)
109 if (e.agentId !== undefined) return out
110 const hasWork = prompt !== '' || files.length > 0 || commits.length > 0
111 if (hasWork) {
112 try {
113 await record($, e.durationMs)
114 } catch {
115 // A full or unreadable store must never break a turn.
116 }
117 }
118 prompt = ''
119 files = []
120 commits = []
121 return out
122 })
123
124 on('command.run', { command: 'standup' }, async ($, e) => {
125 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
126 const today = dayOf(await $.clock.now())
127
128 if (verb === 'clear') {
129 const keys = (await $.store.keys()).filter(k => k.startsWith(DAY_PREFIX) || k === 'last')
130 for (const key of keys) await $.store.delete(key)
131 const days = keys.filter(k => k !== 'last').length
132 return { text: `standup: cleared ${days} day${days === 1 ? '' : 's'} of journal.` }
133 }
134
135 if (verb === 'copy') {
136 const last = await $.store.get('last')
137 if (typeof last !== 'string' || last === '') return { text: 'standup: nothing to copy yet. Run /standup first.' }
138 const copied = await $.ui.copy({ text: last })
139 return { text: copied.isCopied ? 'standup: copied to your clipboard.' : `standup: could not copy (${copied.reason}).` }
140 }
141
142 if (verb === 'raw') {
143 const count = Math.max(1, Math.min(14, Number.parseInt(arg, 10) || 2))
144 const days = await recentDays($, count)
145 if (days.length === 0) return { text: 'standup: the journal is empty. It fills up as you work.' }
146 return { text: journalText(days, today) }
147 }
148
149 if (verb !== '') return { text: 'Usage: /standup, /standup raw [days], /standup copy, /standup clear' }
150
151 // The last working day before today, and today.
152 const known = journalDays(await $.store.keys())
153 const picked = [known.find(d => d < today), known.includes(today) ? today : undefined]
154 const days: { day: string; entries: Entry[] }[] = []
155 for (const day of picked) {
156 if (day === undefined) continue
157 const entries = await readDay($, day)
158 if (entries.length > 0) days.push({ day, entries })
159 }
160 if (days.length === 0) return { text: 'standup: nothing journaled yet. It records each turn from now on; come back after some work.' }
161
162 const journal = journalText(days, today)
163 const reply = await $.model.complete({
164 model,
165 system: SYSTEM,
166 prompt: standupPrompt(journal, [...days.map(d => dayLabel(d.day, today)), 'Blockers']),
167 maxTokens: 600,
168 timeoutMs: 60_000,
169 })
170 if (!reply.isAnswered || reply.text.trim() === '') {
171 const why = reply.isAnswered ? 'empty reply' : reply.reason
172 return { text: `standup: the model was unavailable (${why}), so here is the journal:\n\n${journal}` }
173 }
174 const text = reply.text.trim()
175 await $.store.set('last', text)
176 return { text: `${text}\n\n(/standup copy puts it on your clipboard)` }
177 })
178}
179hooks/journal.ts 167 lines1// The journal's shape and the text made from it. Pure: no `$`.
2
3export type Entry = {
4 /** When the turn ended, ms since the epoch. */
5 at: number
6 project: string
7 prompt: string
8 files: string[]
9 commits: string[]
10 durationMs: number
11}
12
13export const DAY_PREFIX = 'day:'
14export const KEEP_DAYS = 14
15export const MAX_ENTRIES_PER_DAY = 80
16const MAX_FILES = 15
17const MAX_COMMITS = 5
18
19const pad = (n: number) => String(n).padStart(2, '0')
20
21/** The local calendar day of `ms`, `YYYY-MM-DD`. */
22export function dayOf(ms: number): string {
23 const d = new Date(ms)
24 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
25}
26
27/** Local `HH:MM` of `ms`. */
28export function timeOf(ms: number): string {
29 const d = new Date(ms)
30 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
31}
32
33/** One line, whitespace collapsed, cut at `max` with an ellipsis. */
34export function clip(text: string, max: number): string {
35 const line = text.replace(/\s+/g, ' ').trim()
36 return line.length <= max ? line : `${line.slice(0, max - 1).trimEnd()}…`
37}
38
39/** `path` relative to `root` when it lies inside it. */
40export function relativeTo(path: string, root: string): string {
41 const base = root.replace(/[\\/]+$/, '')
42 if (base !== '' && (path.startsWith(`${base}/`) || path.startsWith(`${base}\\`))) return path.slice(base.length + 1)
43 return path
44}
45
46/** The last path segment. */
47export function baseName(path: string): string {
48 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
49}
50
51/** Whether a Bash command line makes a git commit. */
52export function isGitCommit(command: string): boolean {
53 return /(^|[;&|(]\s*|\s)git\s+(?:-[Cc]\s+\S+\s+)*commit\b/.test(command)
54}
55
56/**
57 * The subject of the commit a Bash call made: from git's own output
58 * (`[main 1a2b3c4] Subject`) when it has it, else from `-m` in the command,
59 * heredoc messages included.
60 */
61export function commitSubject(command: string, output: string): string | undefined {
62 const fromGit = /^\[[^\]\n]*\b[0-9a-f]{7,40}\]\s+(.+)$/m.exec(output)
63 if (fromGit?.[1] !== undefined) return clip(fromGit[1], 100)
64
65 const heredoc = /<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\b/.exec(command)
66 if (heredoc?.[2] !== undefined) {
67 const first = heredoc[2].split('\n').map(l => l.trim()).find(l => l !== '')
68 if (first !== undefined) return clip(first, 100)
69 }
70 const flag = /(?:^|\s)(?:-m|--message)(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/.exec(command)
71 const message = flag?.[1] ?? flag?.[2] ?? flag?.[3]
72 if (message !== undefined && !message.startsWith('$(')) {
73 const first = message.replace(/\\n/g, '\n').split('\n')[0] ?? ''
74 if (first.trim() !== '') return clip(first, 100)
75 }
76 return undefined
77}
78
79/** An entry with every field held to its size. */
80export function trimEntry(entry: Entry): Entry {
81 return {
82 at: entry.at,
83 project: clip(entry.project, 60),
84 prompt: clip(entry.prompt, 160),
85 files: [...new Set(entry.files)].slice(0, MAX_FILES).map(f => clip(f, 120)),
86 commits: entry.commits.slice(0, MAX_COMMITS).map(c => clip(c, 100)),
87 durationMs: Math.max(0, Math.round(entry.durationMs)),
88 }
89}
90
91/** The day's entries with `entry` added, the oldest dropped past the cap. */
92export function withEntry(entries: readonly Entry[], entry: Entry): Entry[] {
93 return [...entries, trimEntry(entry)].slice(-MAX_ENTRIES_PER_DAY)
94}
95
96/** The day keys older than the newest `KEEP_DAYS` days ending on `today`. */
97export function expiredKeys(keys: readonly string[], today: string): string[] {
98 const days = keys.filter(k => k.startsWith(DAY_PREFIX)).map(k => k.slice(DAY_PREFIX.length))
99 const cutoff = new Date(`${today}T12:00:00`)
100 cutoff.setDate(cutoff.getDate() - (KEEP_DAYS - 1))
101 const oldest = dayOf(cutoff.getTime())
102 return days.filter(d => d < oldest).map(d => DAY_PREFIX + d)
103}
104
105/** The days with entries, newest first, as `YYYY-MM-DD`. */
106export function journalDays(keys: readonly string[]): string[] {
107 return keys
108 .filter(k => k.startsWith(DAY_PREFIX))
109 .map(k => k.slice(DAY_PREFIX.length))
110 .sort()
111 .reverse()
112}
113
114const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
115
116/** `Today`, `Yesterday`, or the weekday name for an older day. */
117export function dayLabel(day: string, today: string): string {
118 if (day === today) return 'Today'
119 const before = new Date(`${today}T12:00:00`)
120 before.setDate(before.getDate() - 1)
121 if (day === dayOf(before.getTime())) return 'Yesterday'
122 return WEEKDAYS[new Date(`${day}T12:00:00`).getDay()] ?? day
123}
124
125function minutes(ms: number): string {
126 const m = Math.round(ms / 60000)
127 return m < 1 ? '<1m' : `${m}m`
128}
129
130/** The journal as plain text, one block per day and project. */
131export function journalText(days: readonly { day: string; entries: readonly Entry[] }[], today: string): string {
132 const out: string[] = []
133 for (const { day, entries } of days) {
134 out.push(`${dayLabel(day, today)} (${day})`)
135 const projects = [...new Set(entries.map(e => e.project))]
136 for (const project of projects) {
137 if (projects.length > 1 || project !== '') out.push(` ${project === '' ? '(no project)' : project}`)
138 for (const e of entries.filter(x => x.project === project)) {
139 const parts = [` ${timeOf(e.at)} (${minutes(e.durationMs)})`]
140 parts.push(e.prompt === '' ? '(no prompt)' : `"${e.prompt}"`)
141 if (e.files.length > 0) parts.push(`files: ${e.files.join(', ')}`)
142 if (e.commits.length > 0) parts.push(`commits: ${e.commits.map(c => `"${c}"`).join(', ')}`)
143 out.push(parts.join(' · '))
144 }
145 }
146 out.push('')
147 }
148 return out.join('\n').trimEnd()
149}
150
151export const SYSTEM = [
152 "You write a developer's daily standup from their Claude Code journal: one line per turn they ran, with",
153 'the prompt they typed, the files changed and the commits made.',
154 'Write one section per heading you are given, in that order: the heading alone on a line, then "- " bullets.',
155 'A day heading covers that day of the journal; "Blockers" covers the whole journal.',
156 'Rules: merge related turns into one bullet; say what was achieved, in past tense, not what was asked;',
157 'name commits when there are some; prefix a bullet with the project in brackets when there are several',
158 'projects; at most 6 bullets per section; never invent work that is not in the journal. Under Blockers,',
159 'list only something left unresolved in the journal (repeated attempts at one problem, failing tests, an',
160 'interrupted turn), else "- None noted.". No preamble, no closing line, no bold, no other headings.',
161].join('\n')
162
163/** The prompt for the standup model call: the journal, then the headings to write. */
164export function standupPrompt(journal: string, headings: readonly string[]): string {
165 return `Journal:\n\n${journal}\n\nHeadings: ${headings.join(', ')}\n\nWrite the standup.`
166}
167