/standup writes yesterday's standup (since Friday on Mondays) from your git commits and copies it

/standup writes yesterday's standup from your git commits, then copies it.
Yesterday
- Slots snap to the grid (app)
- Added the /slots endpoint (api)
Today
- Fix the empty slot crash
Blockers
- None
It reads every commit by your git config user.email, on all branches, since the start of yesterday, or since Friday morning when it is Saturday, Sunday or Monday. The current repo is always read; add others in the options and they are grouped by repo. A fast model turns the raw list into short bullets; commits made today count as work already under way.
/standup the standup, copied to the clipboard
/standup raw just the commit list, no model
Past Claude Code sessions are not part of it: the mod API has no way to read sessions other than the current one yet, so the standup is built from git alone.
claude --plugin-dir ./standup
or add this repo as a marketplace and install it with /plugin.
| option | default | what it does |
|---|---|---|
repos | empty | More repo paths to read, comma-separated, ~/ expanded: ~/code/api, ~/code/web. A path that is not a repo is named and skipped. |
model | haiku | The model that writes the standup: an alias or a full model id. |
A command.run hook reads the date with date +%Y-%m-%d %u and the commits with git log --all --author=<email> --since=<day> 00:00 through $.process.run, writes the bullets with $.model.complete, and copies them with $.ui.copy.
hooks/register.ts 95 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { buildPrompt, label, parseLog, rawList, repoPaths, sinceDay, tidy } from './days'
4import type { RepoLog } from './days'
5
6async function run($: EngineInterface, argv: string[]): Promise<string | undefined> {
7 const ran = await $.process.run(argv, { timeoutMs: 15_000 }).catch(() => undefined)
8
9 return ran?.exitCode === 0 ? ran.stdout.trim() : undefined
10}
11
12/** Each repo's commits by `email` since the morning of `since`, repos deduplicated by their root. */
13async function collect($: EngineInterface, paths: readonly string[], email: string, since: string): Promise<{ logs: RepoLog[]; missing: string[] }> {
14 const logs: RepoLog[] = []
15 const missing: string[] = []
16 const seen = new Set<string>()
17
18 for (const path of paths) {
19 const root = await run($, ['git', '-C', path, 'rev-parse', '--show-toplevel'])
20
21 if (root === undefined) {
22 missing.push(path)
23 continue
24 }
25
26 if (seen.has(root)) {
27 continue
28 }
29
30 seen.add(root)
31 const stdout = await run($, [
32 'git', '-C', root, 'log', '--all', '--no-merges', '--regexp-ignore-case',
33 `--author=${email}`, `--since=${since} 00:00`,
34 '--format=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M',
35 ])
36 logs.push({ repo: root.split('/').pop() ?? root, commits: parseLog(stdout ?? '') })
37 }
38
39 return { logs, missing }
40}
41
42export const register: Register = (on, options) => {
43 const model = String(options.model ?? 'haiku') || 'haiku'
44 const extraRepos = String(options.repos ?? '')
45
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: 'standup',
49 description: "Write yesterday's standup from your commits (raw: just the commit list)",
50 argumentHint: '[raw]',
51 })
52
53 return next(e)
54 })
55
56 on('command.run', { command: 'standup' }, async ($, e) => {
57 const isRaw = e.args.trim().toLowerCase() === 'raw'
58 const [today = '', isoWeekday = '1'] = ((await run($, ['date', '+%Y-%m-%d %u'])) ?? '').split(' ')
59 const email = await run($, ['git', 'config', 'user.email'])
60
61 if (today === '' || email === undefined || email === '') {
62 return { text: 'standup: no git user.email set (git config --global user.email you@example.com), so there is no telling which commits are yours.' }
63 }
64
65 const since = sinceDay(today, Number(isoWeekday))
66 const paths = [await $.session.cwd(), ...repoPaths(extraRepos, await $.env.get('HOME'))]
67 const { logs, missing } = await collect($, paths, email, since)
68 const count = logs.reduce((sum, log) => sum + log.commits.length, 0)
69 const skipped = missing.length === 0 ? '' : `\n· not a git repo, skipped: ${missing.join(', ')}`
70
71 if (count === 0) {
72 const where = logs.length === 0 ? 'any repo' : logs.map(log => log.repo).join(', ')
73
74 return { text: `No commits by ${email} since ${label(since)} in ${where}.${skipped}` }
75 }
76
77 const list = rawList(logs, today)
78
79 if (isRaw) {
80 return { text: `${count} commit${count === 1 ? '' : 's'} by ${email} since ${label(since)}\n\n${list}${skipped}` }
81 }
82
83 const reply = await $.model.complete({ model, prompt: buildPrompt(list, since, today), maxTokens: 600, timeoutMs: 45_000 })
84
85 if (!reply.isAnswered) {
86 return { text: `standup: the model gave no answer (${reply.reason}). Here are the commits:\n\n${list}${skipped}` }
87 }
88
89 const standup = tidy(reply.text)
90 const copied = await $.ui.copy({ text: standup })
91
92 return { text: `${standup}\n\n· ${copied.isCopied ? 'copied to clipboard' : `not copied (${copied.reason})`} · from ${count} commits since ${label(since)} · /standup raw for the list${skipped}` }
93 })
94}
95hooks/days.ts 107 lines1// Pure helpers for /standup: which days count, reading git log, and the model's prompt.
2
3const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
4const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5
6function utcOf(day: string): Date {
7 const [year = 1970, month = 1, date = 1] = day.split('-').map(Number)
8
9 return new Date(Date.UTC(year, month - 1, date))
10}
11
12/** "2026-10-02" -> "Fri 2 Oct" (calendar arithmetic only, so no time zone gets a say). */
13export function label(day: string): string {
14 const date = utcOf(day)
15
16 return `${DAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
17}
18
19export function weekday(day: string): string {
20 return DAYS[utcOf(day).getUTCDay()] ?? ''
21}
22
23/**
24 * The first day the standup covers: yesterday, or Friday when today is
25 * Saturday, Sunday or Monday. `isoWeekday` is `date +%u`: 1 Monday ... 7 Sunday.
26 */
27export function sinceDay(today: string, isoWeekday: number): string {
28 const back = isoWeekday === 1 ? 3 : isoWeekday === 7 ? 2 : 1
29 const date = utcOf(today)
30 date.setUTCDate(date.getUTCDate() - back)
31
32 return date.toISOString().slice(0, 10)
33}
34
35export type Commit = { sha: string; day: string; time: string; subject: string }
36export type RepoLog = { repo: string; commits: Commit[] }
37
38/** Lines of `git log --format=%h%x09%ad%x09%s --date=format:'%Y-%m-%d %H:%M'`. */
39export function parseLog(stdout: string): Commit[] {
40 return stdout
41 .split('\n')
42 .filter(line => line.trim() !== '')
43 .map(line => {
44 const [sha = '', when = '', ...subject] = line.split('\t')
45 const [day = '', time = ''] = when.split(' ')
46
47 return { sha, day, time, subject: subject.join('\t') }
48 })
49}
50
51/** The repo list, one block per repo, oldest commit first. */
52export function rawList(logs: readonly RepoLog[], today: string): string {
53 return logs
54 .filter(log => log.commits.length > 0)
55 .map(log => {
56 const rows = [...log.commits].reverse().map(commit => ` ${commit.sha} ${commit.day === today ? 'today' : weekday(commit.day)} ${commit.time} ${commit.subject}`)
57
58 return [`${log.repo} (${log.commits.length})`, ...rows].join('\n')
59 })
60 .join('\n\n')
61}
62
63/** Paths from the option: commas or newlines between them, `~/` expanded. */
64export function repoPaths(option: string, home: string | undefined): string[] {
65 return option
66 .split(/[,\n]/)
67 .map(path => path.trim())
68 .filter(Boolean)
69 .map(path => (home !== undefined && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path))
70}
71
72export function buildPrompt(list: string, since: string, today: string): string {
73 return [
74 `Write my daily standup from my git commits since ${label(since)} (today is ${label(today)}).`,
75 '',
76 'Use exactly this shape, plain text, no markdown headings or bold:',
77 'Yesterday',
78 '- ...',
79 'Today',
80 '- ...',
81 'Blockers',
82 '- ...',
83 '',
84 'Rules:',
85 '- Yesterday: 2 to 5 short bullets of what got done, merged from related commits, outcomes over file names, name the repo when there are several',
86 '- Today: 1 to 3 bullets of the natural next steps, judged from where the work stopped (commits marked "today" are already under way)',
87 '- Blockers: "None" unless a commit says something is blocked, reverted or waiting on someone',
88 '- each bullet under 12 words, plain words, no emojis, no em dashes, no filler like "worked on"',
89 '',
90 '<commits>',
91 list,
92 '</commits>',
93 ].join('\n')
94}
95
96/** Keeps the model's three sections and drops anything it wrapped around them. */
97export function tidy(reply: string): string {
98 const text = reply
99 .replace(/\*\*/g, '')
100 .replace(/^#+\s*/gm, '')
101 .replace(/\s*[—–]\s*/g, ', ')
102 .trim()
103 const start = text.search(/^Yesterday\b/m)
104
105 return start === -1 ? text : text.slice(start).trim()
106}
107