SLOPSHOPPER

standup

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

newcommandmodelprocess
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/standup
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · standup
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /standup ⎿ standup: standup: no git user.email set (git config --global user.email you@example.com), so there is no telling which ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

standup

/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.

Install

claude --plugin-dir ./standup

or add this repo as a marketplace and install it with /plugin.

Options

optiondefaultwhat it does
reposemptyMore repo paths to read, comma-separated, ~/ expanded: ~/code/api, ~/code/web. A path that is not a repo is named and skipped.
modelhaikuThe model that writes the standup: an alias or a full model id.

How it works

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.

Source 2 files
hooks/register.ts 95 lines
1import 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}
95
hooks/days.ts 107 lines
1// 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