SLOPSHOPPER

standup

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · standup
│ ┃ Standup · Wed 8 Oct ✕ › fix the failing auth test and add an a╭──────────────────────────╮ │ ┃ Wed 8 Oct · 0 PRs opened · 0 merged · 0 │ standup │ │ ┃ reviewed · 0 commits ⏺ Read(src/auth.ts) │ Standup ready — /standup │ │ ┃ ⚠ GitHub PRs: JSON Parse error: Unexpected ⎿ Read 6 lines ╰──────────────────────────╯ │ ┃ EOF ⏺ Update(src/auth.ts) │ ┃ ⚠ GitHub commits: JSON Parse error: ⎿ Added 2 lines, removed 1 line │ ┃ Unexpected EOF ⏺ Bash(bun test) │ ┃ Nothing found for this day. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Draft standup ] [ Refresh ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /standup │ ⎿ standup: Standup for Wed 8 Oct: 0 PRs opened · 0 merged · 0 revi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Standup · Wed 8 Oct
Wed 8 Oct · 0 PRs opened · 0 merged · 0 reviewed · 0 commits ⚠ GitHub PRs: JSON Parse error: Unexpected EOF ⚠ GitHub commits: JSON Parse error: Unexpected EOF Nothing found for this day. [ Draft standup ] [ Refresh ]
README

Claude Code mods

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.

Install

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.

rules-guard

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:

  • No direct push to a protected branch, including --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.
  • No plain force push. --force-with-lease is fine.
  • No kubectl or aws calls aimed at production (a context, cluster or profile with "prod" in its name).

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.

pr-radar

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.
  • Claude gets two tools, 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

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

usage-pacer

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.

repo-context

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.

stay-awake

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.

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 219 lines
1import { 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}
219
hooks/day.ts 79 lines
1const 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}
79
hooks/gather.ts 216 lines
1import 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}
216
types/index.d.ts 43 lines
1export 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