SLOPSHOPPER

standup

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

newguardcommandpromptmodel
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: OK ⎿ standup: ⎿ standup: (/standup copy puts it on your clipboard) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

📝 standup

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.

Features

  • Journals automatically: every main-thread turn records the time, the project, your prompt, the files changed by successful 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.
  • Works across projects: one journal covers every repo you work in, grouped by project.
  • /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.
  • Small and local: the journal stays on your machine in the mod's store and keeps 14 days, at most 80 turns a day, with every field size-capped. If the model is unavailable, /standup shows the raw journal instead.

Install

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

Configuration

Set this in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
modelhaikuThe model that writes the standup: an alias (haiku, sonnet) or a full model id.

How it works

Event / APIWhy
prompt.submitRecords the prompt you typed (your own prompts only; slash commands and plugin prompts are skipped)
tool.call on Edit, Write, NotebookEdit, BashRecords the files changed and git commit subjects of calls that succeeded
turn.completeWrites the turn's entry to $.store under its day and drops days older than 14
command.run/standup, raw, copy, clear
$.model.completeOne call per /standup (~600 output tokens at most)
$.ui.copy/standup copy

Test it

claude plugin test mods/productivity/standup   # 22 tests

Limitations

  • It only knows what happened through Claude: work you did by hand, or files changed by shell commands like sed -i, isn't in the journal.
  • Each /standup costs one small model call. /standup raw is free.
  • Two sessions finishing a turn at the same instant can race on that day's entry, so one turn may be lost.
Source 2 files
hooks/register.ts 179 lines
1import 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}
179
hooks/journal.ts 167 lines
1// 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