/standup: a pane of the PRs you opened, merged and reviewed, the commits you made and the Claude Code sessions you worked in on a work day, with a button to…

Six small mods for Claude Code, written in TypeScript against its plugin hooks API. They add guard rails on tool calls, status line items, panes and slash commands: blocking risky git and shell commands, tracking your pull requests, gathering a standup, pacing session limits, showing which repo Claude is in, and keeping the Mac awake while work runs. Each mod is its own plugin, so you can install only the ones you want.
Add the marketplace from GitHub or from a local clone:
/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin marketplace add ~/path/to/claude-code-mods
Then install the mods you want:
/plugin install rules-guard@claude-code-mods
/plugin install pr-radar@claude-code-mods
The same works from a shell with claude plugin marketplace add <source> and claude plugin install <name>@claude-code-mods. Options are set with /plugin (pick the plugin, then configure) or claude plugin configure <name>. Restart the session after installing or changing options.
pr-radar and standup call the GitHub CLI, so gh must be installed and logged in. stay-awake and the pr-radar desktop notification are macOS only.
Checks Bash, Write and Edit calls before they run and holds the ones that break a rule. Each block names the rule, the problem, where it was found, the fix, and when to ask the user, so Claude can correct itself and retry.
Always on:
--all, --mirror and a bare git push from a protected branch. A bare push whose branch cannot be told is held until the branch is named.--force-with-lease is fine.Optional (off by default):
noAiAttribution: block AI attribution lines (co-author trailers, session links, "generated with" lines) in commits, PRs, comments and files.noTestPlan: block a "Test plan" section in PR descriptions.prBulletsOnly: PR descriptions must be 20 lines or fewer of short bullet points, with no prose paragraphs.noCodeComments: block new code comments in source files inside a git repo. Tool directives such as @ts- or eslint- lines and shebangs are allowed.noTestFiles: block a git commit that would include test files.noSlackSend: block Claude from sending or scheduling Slack messages. Drafts are allowed.liquibaseAuthor: the only author allowed on Liquibase changesets. Empty turns the check off.vpnProcess: process name that runs while your VPN is up, such as openvpn. When set, kubectl calls are held while it is not running.awsSsoCheck: hold kubectl and aws calls when no AWS SSO session in ~/.aws/sso/cache is fresh.Other options:
protectedBranches: comma-separated branch names. Default main,master.Keeps the state of your open GitHub pull requests across every repo: review decision, CI checks, unresolved threads, conflicts, and PRs merged in the last three days. It shows a summary in the status line, refreshes every five minutes and after gh pr or git push commands, and tells you when a PR is approved, gets changes requested, starts or stops failing, gets new comments, merges or closes.
/prs lists your PRs with links. /prs pane opens a live pane. /prs refresh fetches now./unreleased compares main (or master) with your integration branch in each repo and lists the PRs and commits not yet released.my_prs and my_unreleased, and prompts about PRs carry the live state as context.Options:
alerts: toast and macOS notification on PR changes. Default true.sound: play a sound with the notification. Default true.repos: comma-separated owner/repo list for /unreleased. Default empty, which uses only repos where you have a PR into the integration branch.integrationBranch: the branch work merges into before a release. Default staging.login: your GitHub login, used to mark your own commits in /unreleased. Default empty, which reads it from gh api user./standup opens a pane with what you did on a work day: PRs you opened, merged and reviewed, the commits you made, and the Claude Code sessions you worked in, grouped by repo. With no argument it shows the previous work day. It also accepts today, yesterday or a date like 2026-10-06. The Draft button asks Claude to write the update.
Options:
repos: comma-separated local repo paths to read commits from, including unpushed branches. Paths can be absolute, start with ~/, or be relative to your home folder. Default empty, which uses GitHub search only.utcOffset: your time zone as an offset from UTC, such as +03:00. Default +00:00.workDays: comma-separated work days. Default Mon,Tue,Wed,Thu,Fri.draftCommand: a slash command the Draft button runs with the day as its argument. Default empty, which sends Claude a prompt with the day's activity instead.Shows session-limit usage in the status line with the time until each window resets, and shows a toast once at 80% and once at 95% of each window.
Options:
sonnetSubagentsAbove: when usage of any window is above this percent, general-purpose subagents with no model of their own run on Sonnet. Default 0, which turns it off.handoffAt: when the 5-hour window reaches this percent, run your own /handoff command once in the session you are actively working in. Default 0, which turns it off.Shows the repo and branch Claude is working in on the status line, following cd and git -C in its commands. It also names a new session from its first real prompt, using a short Haiku call.
No options.
Keeps the Mac from idle-sleeping while a turn or a background agent runs, using caffeinate, and lets it sleep again once everything is done.
No options.
MIT. See LICENSE.
hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StandupCommit, StandupData, StandupSession } from '../types'
5import { DEFAULT_OFFSET, DEFAULT_WORK_DAYS, configureDays, isWorkTime, labelOf, resolveDay, todayOf, workArgsOf } from './day'
6import {
7 byRepo,
8 commitArgs,
9 countsOf,
10 historyArgs,
11 logArgs,
12 mergeCommits,
13 parseLog,
14 parsePrs,
15 parseSearchCommits,
16 parseSessions,
17 prArgs,
18 reasonOf,
19 repoOfRemote,
20 reposOf,
21} from './gather'
22
23const PANE = 'standup'
24const MAX_COMMITS = 12
25
26let settings = { repos: '', draftCommand: '' }
27
28const viewAtom = atom({ plugin: 'standup', key: 'view' } as const, null)
29
30async function githubPrs($: EngineInterface, day: string) {
31 const ran = await $.process.run(prArgs(day), { timeoutMs: 30_000 })
32 if (ran.exitCode !== 0) throw new Error(reasonOf(ran.stderr || ran.stdout))
33 return parsePrs(ran.stdout)
34}
35
36async function githubCommits($: EngineInterface, day: string) {
37 const ran = await $.process.run(commitArgs(day), { timeoutMs: 30_000 })
38 if (ran.exitCode !== 0) throw new Error(reasonOf(ran.stderr || ran.stdout))
39 return parseSearchCommits(ran.stdout)
40}
41
42async function localCommits($: EngineInterface, dir: string, day: string): Promise<StandupCommit[]> {
43 const email = await $.process.run(['git', '-C', dir, 'config', 'user.email'], { timeoutMs: 10_000 })
44 if (email.exitCode !== 0 || email.stdout.trim() === '') return []
45 const remote = await $.process.run(['git', '-C', dir, 'remote', 'get-url', 'origin'], { timeoutMs: 10_000 })
46 const repo = repoOfRemote(remote.stdout, dir.split('/').pop() ?? dir)
47 const log = await $.process.run(logArgs(dir, email.stdout.trim(), day), { timeoutMs: 20_000 })
48 return log.exitCode === 0 ? parseLog(log.stdout, repo) : []
49}
50
51async function claudeSessions($: EngineInterface, home: string, day: string): Promise<StandupSession[]> {
52 const ran = await $.process.run(historyArgs(home), { timeoutMs: 10_000 })
53 return ran.exitCode === 0 ? parseSessions(ran.stdout, day, home) : []
54}
55
56async function gather($: EngineInterface, day: string): Promise<StandupData> {
57 const home = (await $.env.get('HOME')) ?? ''
58 const errors: string[] = []
59 const caught = <T,>(label: string, fallback: T) => (error: unknown) => {
60 errors.push(`${label}: ${reasonOf(error instanceof Error ? error.message : String(error))}`)
61 return fallback
62 }
63 const [prs, searched, sessions, ...local] = await Promise.all([
64 githubPrs($, day).catch(caught('GitHub PRs', { opened: [], merged: [], reviewed: [] })),
65 githubCommits($, day).catch(caught('GitHub commits', [] as StandupCommit[])),
66 claudeSessions($, home, day).catch(caught('Claude sessions', [] as StandupSession[])),
67 ...reposOf(settings.repos, home).map(dir => localCommits($, dir, day).catch(() => [] as StandupCommit[])),
68 ])
69 return { ...prs, commits: mergeCommits([searched, ...local]), sessions, errors }
70}
71
72async function show($: EngineInterface, day: string) {
73 const label = labelOf(day)
74 await update($, viewAtom, () => ({ day, label, isLoading: true, data: null }))
75 const data = await gather($, day)
76 await update($, viewAtom, () => ({ day, label, isLoading: false, data }))
77 return data
78}
79
80const draftPrompt = (label: string, data: StandupData) =>
81 [
82 `Draft a short standup update for ${label} from this activity. Plain bullet points grouped by repo, no filler.`,
83 ...byRepo(data).flatMap(activity => [
84 `${activity.repo}:`,
85 ...activity.merged.map(pr => `- merged #${pr.number} ${pr.title} into ${pr.base}`),
86 ...activity.opened.map(pr => `- opened #${pr.number} ${pr.title}`),
87 ...activity.reviewed.map(pr => `- reviewed #${pr.number} ${pr.title}`),
88 ...activity.commits.map(commit => `- commit ${commit.subject}`),
89 ]),
90 ].join('\n')
91
92async function draft($: EngineInterface, day: string) {
93 const command = settings.draftCommand.replace(/^\//, '')
94 if (command === '') {
95 const view = await read($, viewAtom)
96 if (view === null || view.data === null || view.day !== day) return
97 await $.prompt.submit({ text: draftPrompt(view.label, view.data), asUser: true })
98 return
99 }
100 const args = workArgsOf(day, await $.clock.now())
101 try {
102 await $.command.run({ command, args })
103 } catch {
104 await $.prompt.submit({ text: `/${command} ${args}`, asUser: true })
105 }
106}
107
108export const register: Register = (on, options) => {
109 settings = {
110 repos: typeof options.repos === 'string' ? options.repos : '',
111 draftCommand: typeof options.draftCommand === 'string' ? options.draftCommand.trim() : '',
112 }
113 configureDays(
114 typeof options.utcOffset === 'string' && options.utcOffset.trim() !== '' ? options.utcOffset : DEFAULT_OFFSET,
115 typeof options.workDays === 'string' && options.workDays.trim() !== '' ? options.workDays : DEFAULT_WORK_DAYS,
116 )
117
118 on('session.start', async ($, e, next) => {
119 await $.command.register({
120 name: 'standup',
121 description: 'What you shipped on a work day: PRs opened, merged, reviewed and commits (default: previous work day)',
122 argumentHint: '[today|yesterday|YYYY-MM-DD]',
123 })
124 if (e.isInteractive) {
125 const now = await $.clock.now()
126 const today = todayOf(now)
127 if (isWorkTime(now) && (await $.store.get('toastedOn')) !== today) {
128 $.ui.toast('Standup ready — /standup', { timeoutMs: 8_000 })
129 await $.store.set('toastedOn', today)
130 }
131 }
132
133 return next(e)
134 })
135
136 on('command.run', { command: 'standup' }, async ($, e) => {
137 const day = resolveDay(e.args, await $.clock.now())
138 if (day === null) return { text: 'Usage: /standup [today|yesterday|YYYY-MM-DD]' }
139 await $.ui.open({ id: PANE, title: `Standup · ${labelOf(day)}` })
140 const data = await show($, day)
141
142 return { text: `Standup for ${labelOf(day)}: ${countsOf(data)}` }
143 })
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const { Box, Button, Link, Text } = $.ui.resolve(e)
147 const view = await read($, viewAtom)
148 if (view === null) return <Text dimColor>Run /standup to gather a day.</Text>
149 if (view.isLoading || view.data === null) return <Text dimColor>Gathering {view.label}…</Text>
150 const data = view.data
151
152 return (
153 <Box flexDirection="column">
154 <Text bold>
155 {view.label} · {countsOf(data)}
156 </Text>
157 {data.errors.map(error => (
158 <Text color="warning">⚠ {error}</Text>
159 ))}
160 {byRepo(data).length === 0 && data.sessions.length === 0 && <Text dimColor>Nothing found for this day.</Text>}
161 {byRepo(data).map(activity => (
162 <Box flexDirection="column" marginTop={1}>
163 <Text bold>{activity.repo}</Text>
164 {activity.merged.map(pr => (
165 <Link
166 href={pr.url}
167 label={` ${/^(master|main)$/.test(pr.base) ? 'released' : 'merged'} #${pr.number} ${pr.title}`}
168 />
169 ))}
170 {activity.opened
171 .filter(pr => !activity.merged.some(merged => merged.url === pr.url))
172 .map(pr => (
173 <Link href={pr.url} label={` opened #${pr.number} ${pr.title}`} />
174 ))}
175 {activity.reviewed.map(pr => (
176 <Link href={pr.url} label={` reviewed #${pr.number} ${pr.title}`} />
177 ))}
178 {activity.commits.slice(0, MAX_COMMITS).map(commit => (
179 <Text dimColor wrap="truncate-end">
180 {' • '}
181 {commit.subject}
182 </Text>
183 ))}
184 {activity.commits.length > MAX_COMMITS && (
185 <Text dimColor>
186 {' '}+{activity.commits.length - MAX_COMMITS} more commits
187 </Text>
188 )}
189 </Box>
190 ))}
191 {data.sessions.length > 0 && (
192 <Box flexDirection="column" marginTop={1}>
193 <Text bold>Claude sessions</Text>
194 {data.sessions.map(session => (
195 <Box flexDirection="column">
196 <Text wrap="truncate-end">
197 {` ${session.prompts} prompts`}
198 {session.project === '' ? '' : ` · ${session.project}`}
199 {session.firstPrompts[0] === undefined ? '' : ` · ${session.firstPrompts[0]}`}
200 </Text>
201 {session.firstPrompts.slice(1).map(prompt => (
202 <Text dimColor wrap="truncate-end">
203 {' '}
204 {prompt}
205 </Text>
206 ))}
207 </Box>
208 ))}
209 </Box>
210 )}
211 <Box marginTop={1} gap={1}>
212 <Button key="draft" label="Draft standup" hotkey="d" variant="primary" onPress={() => draft($, view.day)} />
213 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => show($, view.day)} />
214 </Box>
215 </Box>
216 )
217 })
218}
219hooks/day.ts 79 lines1const DAY_MS = 86_400_000
2const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
3const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
4
5export const DEFAULT_OFFSET = '+00:00'
6export const DEFAULT_WORK_DAYS = 'Mon,Tue,Wed,Thu,Fri'
7
8let offset = DEFAULT_OFFSET
9let offsetMs = 0
10let workDays = new Set([1, 2, 3, 4, 5])
11
12export const parseOffset = (text: string) => {
13 const match = /^([+-])(\d{2}):?(\d{2})$/.exec(text.trim())
14 if (match === null) return undefined
15 const minutes = Number(match[2]) * 60 + Number(match[3])
16 return minutes > 14 * 60 ? undefined : { text: `${match[1]}${match[2]}:${match[3]}`, ms: (match[1] === '-' ? -1 : 1) * minutes * 60_000 }
17}
18
19export const parseWorkDays = (text: string) => {
20 const days = text
21 .split(',')
22 .map(day => WEEKDAYS.findIndex(name => name.toLowerCase() === day.trim().slice(0, 3).toLowerCase()))
23 .filter(day => day >= 0)
24 return days.length === 0 ? undefined : new Set(days)
25}
26
27export const configureDays = (utcOffset: string, days: string) => {
28 const parsedOffset = parseOffset(utcOffset) ?? parseOffset(DEFAULT_OFFSET)
29 offset = parsedOffset?.text ?? DEFAULT_OFFSET
30 offsetMs = parsedOffset?.ms ?? 0
31 workDays = parseWorkDays(days) ?? parseWorkDays(DEFAULT_WORK_DAYS) ?? workDays
32}
33
34export const offsetOf = () => offset
35
36const local = (now: number) => new Date(now + offsetMs)
37
38const isoOf = (date: Date) => date.toISOString().slice(0, 10)
39
40const isWorkDate = (date: Date) => workDays.has(date.getUTCDay())
41
42export const todayOf = (now: number) => isoOf(local(now))
43
44export const yesterdayOf = (now: number) => isoOf(new Date(local(now).getTime() - DAY_MS))
45
46export const isWorkTime = (now: number) => isWorkDate(local(now)) && local(now).getUTCHours() >= 8
47
48export const previousWorkDay = (now: number) => {
49 let date = new Date(local(now).getTime() - DAY_MS)
50 while (!isWorkDate(date)) date = new Date(date.getTime() - DAY_MS)
51 return isoOf(date)
52}
53
54export const resolveDay = (arg: string, now: number): string | null => {
55 const word = arg.trim().toLowerCase()
56 if (word === '' || word === 'yesterday') return previousWorkDay(now)
57 if (word === 'today') return todayOf(now)
58 if (!/^\d{4}-\d{2}-\d{2}$/.test(word)) return null
59 const date = new Date(`${word}T00:00:00Z`)
60 return Number.isNaN(date.getTime()) || isoOf(date) !== word ? null : word
61}
62
63export const labelOf = (day: string) => {
64 const date = new Date(`${day}T00:00:00Z`)
65 return `${WEEKDAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
66}
67
68export const rangeOf = (day: string) => `${day}T00:00:00${offset}..${day}T23:59:59${offset}`
69
70export const sinceOf = (day: string) => `${day}T00:00:00${offset}`
71
72export const untilOf = (day: string) => `${day}T23:59:59${offset}`
73
74export const workArgsOf = (day: string, now: number) => {
75 if (day === todayOf(now)) return 'today'
76 if (day === yesterdayOf(now)) return 'yesterday'
77 return `${labelOf(day)} (${day})`
78}
79hooks/gather.ts 216 lines1import type { StandupCommit, StandupData, StandupPr, StandupSession } from '../types'
2import { offsetOf, rangeOf, sinceOf, untilOf } from './day'
3
4export const reposOf = (configured: string, home: string) =>
5 configured
6 .split(',')
7 .map(path => path.trim())
8 .filter(Boolean)
9 .map(path => (path === '~' || path.startsWith('~/') ? `${home}${path.slice(1)}` : path.startsWith('/') ? path : `${home}/${path}`))
10
11const HISTORY_BYTES = '4000000'
12const DAY_MS = 86_400_000
13const PROMPT_CHARS = 90
14const SHORT_REPLY = /^(y|yes|n|no|ok|okay|sure|go|go on|do it|continue|retry|again|done|thanks|thank you|good|nothing)\W*$/i
15const MARKERS = /\[(?:Image|Pasted text)[^\]]*\]/g
16
17const QUERY = `query($opened: String!, $merged: String!, $reviewed: String!) {
18 opened: search(query: $opened, type: ISSUE, first: 50) {
19 nodes { ... on PullRequest { number title url baseRefName repository { nameWithOwner } } }
20 }
21 merged: search(query: $merged, type: ISSUE, first: 50) {
22 nodes { ... on PullRequest { number title url baseRefName repository { nameWithOwner } } }
23 }
24 reviewed: search(query: $reviewed, type: ISSUE, first: 50) {
25 nodes { ... on PullRequest { number title url baseRefName repository { nameWithOwner } } }
26 }
27}`
28
29type RawPr = {
30 number?: number
31 title?: string
32 url?: string
33 baseRefName?: string
34 repository?: { nameWithOwner: string }
35}
36
37type RawSearch = { nodes?: RawPr[] }
38
39type RawPrs = {
40 data?: { opened?: RawSearch; merged?: RawSearch; reviewed?: RawSearch }
41 errors?: { message?: string }[]
42}
43
44type RawCommit = {
45 sha?: string
46 commit?: { message?: string }
47 repository?: { name?: string }
48}
49
50export const prArgs = (day: string) => [
51 'gh',
52 'api',
53 'graphql',
54 '-f',
55 `query=${QUERY}`,
56 '-f',
57 `opened=author:@me is:pr created:${rangeOf(day)}`,
58 '-f',
59 `merged=author:@me is:pr is:merged merged:${rangeOf(day)}`,
60 '-f',
61 `reviewed=reviewed-by:@me -author:@me is:pr updated:${rangeOf(day)}`,
62]
63
64export const commitArgs = (day: string) => [
65 'gh',
66 'search',
67 'commits',
68 '--author=@me',
69 `--committer-date=${rangeOf(day)}`,
70 '--json',
71 'sha,commit,repository',
72 '--limit',
73 '100',
74]
75
76export const logArgs = (dir: string, email: string, day: string) => [
77 'git',
78 '-C',
79 dir,
80 'log',
81 '--branches',
82 '--remotes',
83 '--no-merges',
84 '--fixed-strings',
85 `--author=${email}`,
86 `--since=${sinceOf(day)}`,
87 `--until=${untilOf(day)}`,
88 '--format=%H%x1f%s',
89]
90
91export const shortRepo = (repo: string) => repo.split('/').pop() ?? repo
92
93export const repoOfRemote = (url: string, fallback: string) =>
94 /[/:]([^/:]+?)(\.git)?\s*$/.exec(url.trim())?.[1] ?? fallback
95
96const isMerge = (subject: string) => /^Merge (pull request|branch|remote-tracking branch)\b/.test(subject)
97
98const toPrs = (search: RawSearch | undefined): StandupPr[] =>
99 (search?.nodes ?? []).flatMap(node =>
100 node.number === undefined || node.url === undefined
101 ? []
102 : [
103 {
104 repo: shortRepo(node.repository?.nameWithOwner ?? ''),
105 number: node.number,
106 title: node.title ?? '',
107 url: node.url,
108 base: node.baseRefName ?? '',
109 },
110 ],
111 )
112
113export const parsePrs = (stdout: string) => {
114 const raw = JSON.parse(stdout) as RawPrs
115 if (raw.data === undefined) throw new Error(raw.errors?.[0]?.message ?? 'no data from GitHub')
116 return { opened: toPrs(raw.data.opened), merged: toPrs(raw.data.merged), reviewed: toPrs(raw.data.reviewed) }
117}
118
119export const parseSearchCommits = (stdout: string): StandupCommit[] =>
120 (JSON.parse(stdout) as RawCommit[]).flatMap(item => {
121 const subject = item.commit?.message?.split('\n')[0] ?? ''
122 return item.sha === undefined || isMerge(subject)
123 ? []
124 : [{ repo: item.repository?.name ?? '', sha: item.sha, subject }]
125 })
126
127export const parseLog = (stdout: string, repo: string): StandupCommit[] =>
128 stdout
129 .split('\n')
130 .filter(line => line.includes('\x1f'))
131 .map(line => {
132 const [sha = '', subject = ''] = line.split('\x1f')
133 return { repo, sha, subject }
134 })
135
136export const mergeCommits = (lists: readonly StandupCommit[][]) => {
137 const seen = new Set<string>()
138 return lists.flat().filter(commit => {
139 if (seen.has(commit.sha)) return false
140 seen.add(commit.sha)
141 return true
142 })
143}
144
145export const reasonOf = (text: string) => {
146 if (/gh auth login|not logged in|authentication|HTTP 401|Bad credentials/i.test(text)) return 'gh not logged in'
147 if (/could not resolve|no such host|dial tcp|network is unreachable|timed? ?out|connection refused/i.test(text))
148 return 'offline'
149 return text.trim().split('\n')[0]?.slice(0, 80) || 'failed'
150}
151
152export const countsOf = (data: StandupData) =>
153 [
154 `${data.opened.length} PRs opened`,
155 `${data.merged.length} merged`,
156 `${data.reviewed.length} reviewed`,
157 `${data.commits.length} commits`,
158 ...(data.sessions.length > 0 ? [`${data.sessions.length} Claude sessions`] : []),
159 ].join(' · ')
160
161export const historyArgs = (home: string) => ['tail', '-c', HISTORY_BYTES, `${home}/.claude/history.jsonl`]
162
163type RawPrompt = { display?: unknown; timestamp?: unknown; project?: unknown; sessionId?: unknown }
164
165const meaningful = (display: string) => {
166 const text = display.replace(MARKERS, ' ').replace(/\s+/g, ' ').trim()
167 if (text === '' || text.startsWith('/') || text.startsWith('!') || SHORT_REPLY.test(text)) return undefined
168 return text.length > PROMPT_CHARS ? `${text.slice(0, PROMPT_CHARS - 1)}…` : text
169}
170
171export const parseSessions = (stdout: string, day: string, home: string): StandupSession[] => {
172 const start = Date.parse(`${day}T00:00:00${offsetOf()}`)
173 const end = start + DAY_MS
174 const sessions = new Map<string, StandupSession>()
175 for (const line of stdout.split('\n')) {
176 let row: RawPrompt
177 try {
178 row = JSON.parse(line) as RawPrompt
179 } catch {
180 continue
181 }
182 if (typeof row.timestamp !== 'number' || row.timestamp < start || row.timestamp >= end) continue
183 if (typeof row.sessionId !== 'string' || typeof row.display !== 'string') continue
184 const project = typeof row.project === 'string' && row.project !== home ? row.project.replace(home, '~') : ''
185 const session = sessions.get(row.sessionId) ?? { id: row.sessionId, project, prompts: 0, firstPrompts: [] }
186 sessions.set(row.sessionId, session)
187 session.prompts += 1
188 const text = meaningful(row.display)
189 if (text !== undefined && session.firstPrompts.length < 2) session.firstPrompts.push(text)
190 }
191 return [...sessions.values()]
192}
193
194export type RepoActivity = {
195 repo: string
196 opened: StandupPr[]
197 merged: StandupPr[]
198 reviewed: StandupPr[]
199 commits: StandupCommit[]
200}
201
202export const byRepo = (data: StandupData): RepoActivity[] => {
203 const repos = new Map<string, RepoActivity>()
204 const entry = (repo: string) => {
205 const found = repos.get(repo) ?? { repo, opened: [], merged: [], reviewed: [], commits: [] }
206 repos.set(repo, found)
207 return found
208 }
209 for (const pr of data.opened) entry(pr.repo).opened.push(pr)
210 for (const pr of data.merged) entry(pr.repo).merged.push(pr)
211 for (const pr of data.reviewed) entry(pr.repo).reviewed.push(pr)
212 for (const commit of data.commits) entry(commit.repo).commits.push(commit)
213 const size = (a: RepoActivity) => a.opened.length + a.merged.length + a.reviewed.length + a.commits.length
214 return [...repos.values()].sort((a, b) => size(b) - size(a))
215}
216types/index.d.ts 43 lines1export type StandupPr = {
2 repo: string
3 number: number
4 title: string
5 url: string
6 base: string
7}
8
9export type StandupCommit = {
10 repo: string
11 sha: string
12 subject: string
13}
14
15export type StandupSession = {
16 id: string
17 project: string
18 prompts: number
19 firstPrompts: string[]
20}
21
22export type StandupData = {
23 opened: StandupPr[]
24 merged: StandupPr[]
25 reviewed: StandupPr[]
26 commits: StandupCommit[]
27 sessions: StandupSession[]
28 errors: string[]
29}
30
31export type StandupView = {
32 day: string
33 label: string
34 isLoading: boolean
35 data: StandupData | null
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 standup: { view: StandupView | null }
41 }
42}
43