/standup: click Generate and get a written standup of everything you got done today, grouped by project, from your local Claude Code sessions.

Click Generate and get a written standup of what you got done, grouped by project, from your local Claude Code sessions.
/standup opens a pane with Generate, Today and Yesterday buttons. Generate reads the day's session transcripts, boils each session down to a digest (what you asked, the commits it made, the files it changed, and the assistant's closing message), has a small model write the "done" and "open" bullets, then merges every project's sessions into one markdown standup. The result is shown in the pane and saved as ~/standups/YYYY-MM-DD.md.
# Standup · 2026-10-10
## peptideiq-mobile-app
- Fixed the token refresh race in the auth client
- Added retry with backoff to the API wrapper
**Open**
- Wire up the push notification test
_2 sessions · 5 files changed · 3 commits_
What counts as "done": commits, files changed and the assistant's closing message. A request alone never counts as completed work. If nothing finished, the goal is listed under Open.
Cost: one small-model call per session, clipped to about 6 KB, so a busy day with 20 sessions costs a few cents on Haiku 5.5. Each session's summary is cached, so pressing Generate again only pays for sessions that changed.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install standup@mod-squad
/standup: opens the pane./standup today, /standup yesterday, /standup 2026-10-08: picks that day and generates it.| Setting | Default | Meaning |
|---|---|---|
model | claude-haiku-5-5 | Writes each session's summary. Haiku is the default because this is high-volume summarizing, about 20x cheaper than Sonnet. Use claude-sonnet-5-5 for sharper wording. |
effort | low | How hard the model thinks: low, medium, high, xhigh or max. Low is plenty for short summaries. |
folder | blank (~/standups) | Where each standup is saved. Type off to save nothing. |
maxSessions | 40 | Most sessions summarized per standup, newest activity first. |
hooks/register.tsx 251 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Result, RunStatus } from '../types'
5import { SYSTEM, digestText, fallbackSummary, parseSummary, renderMarkdown } from './digest'
6import type { Entry, Summary } from './digest'
7import { dayKey, dayStart, parseSession } from './transcripts'
8import type { SessionDigest } from './transcripts'
9
10const PANE = 'standup'
11const FS_READ_MAX = 4_000_000
12/** Files over the read limit are read from their last ~3.5 MB, which holds the recent days. */
13const TAIL_BYTES = 3_500_000
14const BATCH = 4
15const DEFAULT_MODEL = 'claude-haiku-5-5'
16const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
17type Effort = (typeof EFFORTS)[number]
18// Higher effort spends output tokens thinking before the JSON, so the cap grows with it.
19const MAX_TOKENS: Record<Effort, number> = { low: 400, medium: 2000, high: 6000, xhigh: 12000, max: 24000 }
20
21const IDLE: RunStatus = { isRunning: false, done: 0, total: 0 }
22const resultRef = atom({ plugin: 'standup', key: 'result' } as const, null as Result | null)
23const statusRef = atom({ plugin: 'standup', key: 'status' } as const, IDLE)
24const pickRef = atom({ plugin: 'standup', key: 'pick' } as const, 'today')
25
26type Options = { model?: string; effort?: string; folder?: string; maxSessions?: number }
27type Cfg = { model: string; effort: Effort; maxTokens: number; folder: string; maxSessions: number }
28type Found = { path: string; size: number; mtime: number }
29
30let isBusy = false
31
32function config(options: unknown): Cfg {
33 const o = (options ?? {}) as Options
34 const n = Number(o.maxSessions)
35 return {
36 model: o.model?.trim() || DEFAULT_MODEL,
37 effort: EFFORTS.includes(o.effort as Effort) ? (o.effort as Effort) : 'low',
38 maxTokens: MAX_TOKENS[EFFORTS.includes(o.effort as Effort) ? (o.effort as Effort) : 'low'],
39 folder: (o.folder ?? '').trim(),
40 maxSessions: Number.isFinite(n) && n > 0 ? Math.floor(n) : 40,
41 }
42}
43
44async function tzOffset($: EngineInterface): Promise<number> {
45 try {
46 const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
47 const m = /^([+-])(\d{2})(\d{2})/.exec(r.stdout.trim())
48 if (m) return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
49 } catch {
50 // No process available: use the environment's own offset.
51 }
52 return -new Date().getTimezoneOffset()
53}
54
55async function home($: EngineInterface): Promise<string> {
56 const h = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
57 return h.replace(/[\\/]+$/, '')
58}
59
60async function configRoots($: EngineInterface): Promise<string[]> {
61 const roots: string[] = []
62 const configured = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
63 if (configured) for (const p of configured.split(',')) if (p.trim() !== '') roots.push(p.trim())
64 const h = await home($)
65 if (h !== '') roots.push(`${h}/.claude`)
66 return [...new Set(roots.map(r => r.replace(/[\\/]+$/, '')))]
67}
68
69async function walk($: EngineInterface, dir: string, depth: number, out: Found[]) {
70 const entries = await $.fs.list(dir).catch(() => [])
71 for (const en of entries) {
72 const path = `${dir}/${en.name}`
73 if (en.kind === 'file' && en.name.endsWith('.jsonl')) out.push({ path, size: en.size, mtime: en.mtimeMs })
74 // Subagent transcripts are part of their parent session's work, so they are not listed on their own.
75 else if (en.kind === 'dir' && depth > 0 && en.name !== 'subagents' && en.name !== 'tool-results') await walk($, path, depth - 1, out)
76 }
77}
78
79async function readTranscript($: EngineInterface, f: Found): Promise<string> {
80 if (f.size <= FS_READ_MAX) return $.fs.read(f.path)
81 const r = await $.process.run(['sh', '-c', 'tail -c "$1" "$2"', 'standup', String(TAIL_BYTES), f.path], { timeoutMs: 30_000 })
82 return r.stdout
83}
84
85/** Resolves `today`, `yesterday` or YYYY-MM-DD to a local day. Null for anything else. */
86function resolveDay(pick: string, now: number, tz: number): string | null {
87 if (pick === 'today' || pick === '') return dayKey(now, tz)
88 if (pick === 'yesterday') return dayKey(now - 86_400_000, tz)
89 return /^\d{4}-\d{2}-\d{2}$/.test(pick) ? pick : null
90}
91
92async function summarize($: EngineInterface, d: SessionDigest, day: string, tz: number, cfg: Cfg): Promise<Entry> {
93 const key = `s:${d.sid}:${day}:${d.last}`
94 const cached = (await $.store.get(key).catch(() => undefined)) as Summary | undefined
95 if (cached !== undefined) return { d, s: cached, isModel: true }
96 const r = await $.model
97 .complete({ model: cfg.model, system: SYSTEM, prompt: digestText(d, tz), effort: cfg.effort, maxTokens: cfg.maxTokens, timeoutMs: cfg.effort === 'low' ? 25_000 : 120_000 })
98 .catch(() => null)
99 const s = r?.isAnswered ? parseSummary(r.text) : null
100 if (s === null) return { d, s: fallbackSummary(d), isModel: false }
101 await $.store.set(key, s).catch(() => undefined)
102 return { d, s, isModel: true }
103}
104
105async function save($: EngineInterface, cfg: Cfg, day: string, markdown: string): Promise<string> {
106 if (cfg.folder.toLowerCase() === 'off') return ''
107 const dir = cfg.folder !== '' ? cfg.folder.replace(/[\\/]+$/, '') : `${await home($)}/standups`
108 if (dir === '/standups') return '' // no home folder to put it in
109 const path = `${dir}/${day}.md`
110 try {
111 await $.fs.write(path, markdown)
112 return path
113 } catch {
114 return ''
115 }
116}
117
118async function generate($: EngineInterface, cfg: Cfg) {
119 if (isBusy) return
120 isBusy = true
121 try {
122 await update($, statusRef, () => ({ isRunning: true, done: 0, total: 0 }))
123 const tz = await tzOffset($)
124 const day = resolveDay(await read($, pickRef), await $.clock.now(), tz)
125 if (day === null) throw new Error('pick today, yesterday or a YYYY-MM-DD date')
126 const since = dayStart(day, tz)
127
128 const found: Found[] = []
129 for (const root of await configRoots($)) await walk($, `${root}/projects`, 2, found)
130 const recent = [...new Map(found.filter(f => f.mtime >= since).map(f => [f.path, f])).values()].sort((a, b) => b.mtime - a.mtime)
131
132 const digests: SessionDigest[] = []
133 for (const f of recent) {
134 try {
135 const d = parseSession(await readTranscript($, f), day, tz)
136 if (d !== null && d.sid !== '') digests.push(d)
137 } catch {
138 // An unreadable file is skipped; the rest still make the standup.
139 }
140 }
141 // A session resumed from another file shows up twice; keep the busier one.
142 const bySid = new Map<string, SessionDigest>()
143 for (const d of digests) {
144 const prev = bySid.get(d.sid)
145 if (prev === undefined || d.prompts.length > prev.prompts.length) bySid.set(d.sid, d)
146 }
147 const picked = [...bySid.values()].sort((a, b) => b.last - a.last).slice(0, cfg.maxSessions)
148 await update($, statusRef, () => ({ isRunning: true, done: 0, total: picked.length }))
149
150 const entries: Entry[] = []
151 for (let i = 0; i < picked.length; i += BATCH) {
152 entries.push(...(await Promise.all(picked.slice(i, i + BATCH).map(d => summarize($, d, day, tz, cfg)))))
153 await update($, statusRef, () => ({ isRunning: true, done: Math.min(i + BATCH, picked.length), total: picked.length }))
154 }
155
156 const markdown = renderMarkdown(day, entries)
157 const path = await save($, cfg, day, markdown)
158 const result: Result = {
159 day,
160 markdown,
161 projects: new Set(entries.map(e => e.d.project)).size,
162 sessions: entries.length,
163 summarized: entries.filter(e => e.isModel).length,
164 fallback: entries.filter(e => !e.isModel).length,
165 path,
166 }
167 await update($, resultRef, () => result)
168 await $.store.set('last', result).catch(() => undefined)
169 await update($, statusRef, () => IDLE)
170 $.ui.toast(`standup: ${result.sessions} session${result.sessions === 1 ? '' : 's'}, ${result.projects} project${result.projects === 1 ? '' : 's'}${path !== '' ? `, saved to ${path}` : ''}`)
171 } catch (err) {
172 const error = err instanceof Error ? err.message : String(err)
173 await update($, statusRef, () => ({ isRunning: false, done: 0, total: 0, error })).catch(() => undefined)
174 } finally {
175 isBusy = false
176 }
177}
178
179/** Starts a run on the clock, so no hook waits for it. */
180function start($: EngineInterface, cfg: Cfg) {
181 $.clock.after(1, () => {
182 void generate($, cfg)
183 })
184}
185
186async function choose($: EngineInterface, pick: string) {
187 await update($, pickRef, () => pick)
188}
189
190const OPEN = { id: PANE, title: 'Standup', focus: true, closeOnEscape: true, rows: 40, columns: 100 } as const
191
192export const register: Register = (on, options) => {
193 const cfg = config(options)
194
195 on('session.start', async ($, e, next) => {
196 await $.command.register({
197 name: 'standup',
198 description: 'Standup: write up what you got done, from your local sessions',
199 argumentHint: '[today | yesterday | YYYY-MM-DD]',
200 immediate: true,
201 })
202 const last = (await $.store.get('last').catch(() => undefined)) as Result | undefined
203 if (last !== undefined) await update($, resultRef, () => last)
204 return next(e)
205 }).catch(($, e, next) => next(e))
206
207 on('command.run', { command: 'standup' }, async ($, e) => {
208 const arg = e.args.trim().toLowerCase()
209 await $.ui.open(OPEN).catch(() => undefined)
210 if (arg !== '') {
211 await choose($, arg)
212 start($, cfg)
213 return { text: `Standup for ${arg}: generating…` }
214 }
215 const r = await read($, resultRef)
216 return { text: r === null ? 'Standup: press Generate in the pane.' : `Standup (${r.day}): ${r.sessions} sessions, ${r.projects} projects${r.path !== '' ? `, saved to ${r.path}` : ''}.` }
217 })
218
219 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
221 const result = await read($, resultRef)
222 const status = await read($, statusRef)
223 const pick = await read($, pickRef)
224 const cols = Math.max(20, e.props.bodyColumns)
225
226 const note = status.isRunning
227 ? `working… ${status.total > 0 ? `${status.done}/${status.total} sessions` : 'reading transcripts'}`
228 : status.error !== undefined
229 ? `failed: ${status.error}`
230 : result !== null
231 ? `${result.day} · ${result.sessions} sessions · ${result.projects} projects${result.fallback > 0 ? ` · ${result.fallback} without a model summary` : ''}${result.path !== '' ? ` · saved to ${result.path}` : ''}`
232 : 'Pick a day, then Generate.'
233
234 return (
235 <Box flexDirection="column" width={cols}>
236 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={cols}>
237 <Button key="gen" label="Generate" hotkey="g" variant="primary" onPress={() => start($, cfg)} />
238 <Button key="today" label="Today" hotkey="t" variant={pick === 'today' ? 'primary' : 'secondary'} onPress={() => choose($, 'today')} />
239 <Button key="yday" label="Yesterday" hotkey="y" variant={pick === 'yesterday' ? 'primary' : 'secondary'} onPress={() => choose($, 'yesterday')} />
240 </Box>
241 <Text dimColor wrap="wrap">
242 {note}
243 </Text>
244 <Box marginTop={1} flexDirection="column" width={cols}>
245 {result !== null && <Markdown text={result.markdown} />}
246 </Box>
247 </Box>
248 )
249 })
250}
251hooks/digest.ts 77 lines1import { clip } from './transcripts'
2import type { SessionDigest } from './transcripts'
3
4export type Summary = { title: string; done: string[]; open: string[] }
5
6export const SYSTEM = `You write standup notes from a digest of one coding-assistant session.
7Reply with JSON only: {"title":"<= 8 words","done":["past-tense bullet, <= 14 words", ... at most 5],"open":["unfinished item, <= 14 words", ... at most 3]}
8"done" lists only what the digest shows was completed: commits, files changed, the assistant's closing message. Do not infer success from a request alone; if nothing was finished, leave "done" empty and put the goal in "open".
9Be specific (name the feature, bug or file), not generic. No markdown, no preamble.`
10
11const hhmm = (ms: number, tz: number) => new Date(ms + tz * 60_000).toISOString().slice(11, 16)
12
13/** The text sent to the model for one session. About 6 KB at most. */
14export function digestText(d: SessionDigest, tz: number): string {
15 const lines = [`Project: ${d.project || 'unknown'}`, `Active: ${hhmm(d.first, tz)}-${hhmm(d.last, tz)}`]
16 lines.push('Requests, in order:', ...d.prompts.slice(0, 15).map(p => `- ${clip(p, 300)}`))
17 if (d.prompts.length > 15) lines.push(`- (${d.prompts.length - 15} more)`)
18 if (d.commits.length > 0) lines.push('Commits:', ...d.commits.slice(0, 10).map(c => `- ${c}`))
19 if (d.files.length > 0) lines.push(`Files changed (${d.files.length}):`, ...d.files.slice(0, 20).map(f => `- ${f}`))
20 if (d.answer !== '') lines.push('Assistant\'s last message:', clip(d.answer, 700))
21 return clip(lines.join('\n'), 6000)
22}
23
24export function parseSummary(reply: string): Summary | null {
25 const json = reply.match(/\{[\s\S]*\}/)?.[0]
26 if (!json) return null
27 try {
28 const v = JSON.parse(json) as Partial<Summary>
29 const list = (x: unknown, n: number) =>
30 Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map(s => clip(s.trim(), 140)).slice(0, n) : []
31 const title = typeof v.title === 'string' ? clip(v.title.trim(), 80) : ''
32 const done = list(v.done, 5)
33 const open = list(v.open, 3)
34 return title === '' && done.length === 0 && open.length === 0 ? null : { title, done, open }
35 } catch {
36 return null
37 }
38}
39
40/** No model: commits when there are any, else the files touched; the first request as the goal. */
41export function fallbackSummary(d: SessionDigest): Summary {
42 const goal = clip(d.prompts[0] ?? 'Session', 80)
43 if (d.commits.length > 0) return { title: goal, done: d.commits.slice(0, 5), open: [] }
44 if (d.files.length > 0) {
45 const names = d.files.slice(0, 3).map(f => f.split(/[\\/]/).pop() ?? f)
46 return { title: goal, done: [`Edited ${d.files.length} file${d.files.length === 1 ? '' : 's'}: ${names.join(', ')}${d.files.length > 3 ? '…' : ''}`], open: [] }
47 }
48 return { title: goal, done: [], open: [goal] }
49}
50
51export type Entry = { d: SessionDigest; s: Summary; isModel: boolean }
52
53/** Standup markdown: one section per project, bullets merged across that project's sessions. */
54export function renderMarkdown(day: string, entries: Entry[]): string {
55 const byProject = new Map<string, Entry[]>()
56 for (const e of entries) byProject.set(e.d.project || 'unknown', [...(byProject.get(e.d.project || 'unknown') ?? []), e])
57 const out = [`# Standup · ${day}`, '']
58 if (entries.length === 0) return `${out.join('\n')}Nothing to report: no Claude Code activity found for this day.\n`
59
60 const dedupe = (xs: string[]) => {
61 const seen = new Set<string>()
62 return xs.filter(x => (seen.has(x.toLowerCase()) ? false : (seen.add(x.toLowerCase()), true)))
63 }
64 for (const [project, es] of [...byProject].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) {
65 out.push(`## ${project}`)
66 const done = dedupe(es.flatMap(e => e.s.done))
67 const open = dedupe(es.flatMap(e => e.s.open))
68 if (done.length === 0) out.push('- _No completed work found; see open items._')
69 for (const x of done) out.push(`- ${x}`)
70 if (open.length > 0) out.push('', '**Open**', ...open.map(x => `- ${x}`))
71 const files = new Set(es.flatMap(e => e.d.files)).size
72 const commits = es.reduce((n, e) => n + e.d.commits.length, 0)
73 out.push('', `_${es.length} session${es.length === 1 ? '' : 's'} · ${files} file${files === 1 ? '' : 's'} changed${commits > 0 ? ` · ${commits} commit${commits === 1 ? '' : 's'}` : ''}_`, '')
74 }
75 return `${out.join('\n').trimEnd()}\n`
76}
77hooks/transcripts.ts 102 lines1// Reads one Claude Code transcript (JSONL) and boils a single local day of it down to a digest.
2
3export type SessionDigest = {
4 sid: string
5 project: string
6 cwd: string
7 first: number
8 last: number
9 prompts: string[]
10 files: string[]
11 commits: string[]
12 commands: number
13 answer: string
14}
15
16const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
17// Notices and caveats the harness injects as "user" rows are not things the person typed.
18const NOT_TYPED = /^(<|\[Request interrupted|Caveat:)/
19
20/** YYYY-MM-DD of `ms` shifted by `tzOffsetMin` (minutes east of UTC). */
21export function dayKey(ms: number, tzOffsetMin: number): string {
22 return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10)
23}
24
25/** Epoch ms of local midnight at the start of `day`. */
26export function dayStart(day: string, tzOffsetMin: number): number {
27 return Date.parse(`${day}T00:00:00Z`) - tzOffsetMin * 60_000
28}
29
30/** `/home/me/src/app` → `app`; `C:\\work\\api\\` → `api`. */
31export function shortProject(cwd: string): string {
32 const parts = cwd.split(/[\\/]+/).filter(Boolean)
33 return parts[parts.length - 1] ?? cwd
34}
35
36export const clip = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
37
38function userText(content: unknown): string {
39 if (typeof content === 'string') return content
40 if (!Array.isArray(content)) return ''
41 return content
42 .filter((b): b is { type: string; text: string } => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'text')
43 .map(b => b.text)
44 .join('\n')
45}
46
47/** First line of the message of a `git commit`, quoted (`-m "…"`) or heredoc'd, or null. */
48export function commitMessage(command: string): string | null {
49 if (!/\bgit\s+commit\b/.test(command)) return null
50 const heredoc = /<<-?\s*['"]?(\w+)['"]?\s*\n\s*([^\n]+)/.exec(command)
51 if (heredoc?.[2]) return clip(heredoc[2].trim(), 120)
52 const quoted = /-m\s+(?:"([^"]+)"|'([^']+)')/.exec(command)
53 const msg = quoted?.[1] ?? quoted?.[2]
54 return msg ? clip(msg.split('\n')[0]?.trim() ?? msg, 120) : null
55}
56
57/** What happened in one transcript on `day`, or null when the person did nothing that day. */
58export function parseSession(text: string, day: string, tz: number): SessionDigest | null {
59 const d: SessionDigest = { sid: '', project: '', cwd: '', first: 0, last: 0, prompts: [], files: [], commits: [], commands: 0, answer: '' }
60 const files = new Set<string>()
61 for (const line of text.split('\n')) {
62 if (line.length < 2) continue
63 let o: Record<string, any>
64 try {
65 o = JSON.parse(line)
66 } catch {
67 continue // a cut first line (tail read) or one still being written
68 }
69 if (o.isSidechain === true) continue
70 const ts = Date.parse(String(o.timestamp ?? ''))
71 if (Number.isNaN(ts) || dayKey(ts, tz) !== day) continue
72 if (typeof o.sessionId === 'string') d.sid = o.sessionId
73 if (typeof o.cwd === 'string' && o.cwd !== '') d.cwd = o.cwd
74 d.first = d.first === 0 ? ts : Math.min(d.first, ts)
75 d.last = Math.max(d.last, ts)
76
77 if (o.type === 'user' && o.isMeta !== true) {
78 const t = userText(o.message?.content).trim()
79 if (t !== '' && !NOT_TYPED.test(t)) d.prompts.push(clip(t.replace(/\s+/g, ' '), 400))
80 } else if (o.type === 'assistant' && Array.isArray(o.message?.content)) {
81 for (const b of o.message.content) {
82 if (b?.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '') d.answer = b.text.trim()
83 else if (b?.type === 'tool_use') {
84 const input = b.input ?? {}
85 if (EDIT_TOOLS.has(b.name)) {
86 const f = input.file_path ?? input.notebook_path
87 if (typeof f === 'string') files.add(f)
88 } else if (b.name === 'Bash' && typeof input.command === 'string') {
89 d.commands++
90 const msg = commitMessage(input.command)
91 if (msg !== null) d.commits.push(msg)
92 }
93 }
94 }
95 }
96 }
97 if (d.prompts.length === 0 && files.size === 0) return null
98 d.project = shortProject(d.cwd)
99 d.files = [...files].map(f => (d.cwd !== '' && f.startsWith(d.cwd) ? f.slice(d.cwd.length).replace(/^[\\/]+/, '') : f))
100 return d
101}
102types/index.d.ts 25 lines1export type Result = {
2 day: string
3 markdown: string
4 projects: number
5 sessions: number
6 /** Sessions the model summarized / sessions that fell back to a plain digest. */
7 summarized: number
8 fallback: number
9 /** Where the markdown was saved, or '' when saving is off or failed. */
10 path: string
11}
12
13export type RunStatus = { isRunning: boolean; done: number; total: number; error?: string }
14
15declare module 'claude-code' {
16 interface PluginState {
17 standup: {
18 result: Result | null
19 status: RunStatus
20 /** The day picked in the pane: today | yesterday | YYYY-MM-DD. */
21 pick: string
22 }
23 }
24}
25