SLOPSHOPPER

jr-commit-coach

Nudges you toward small atomic commits: when uncommitted changes pass a threshold, a band and a toast suggest a split by folder.

newbandcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-commit-coach
› 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 › /jrcommit ⎿ jr-commit-coach: jr commit coach · hidden (/jrcommit to bring it back) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jr-commit-coach

Keeps commits small. After each turn it measures the uncommitted work in the repository; past a threshold, a band and a toast suggest an atomic commit and how to split it.

▲ 364 lines uncommitted + 1 new · split: src/ docs/ root/

One row on the left, only while there is something to act on.

jr commit coach · 364 lines uncommitted, 1 new file: commit in small steps, e.g. src (320 lines, 2 files), docs (40 lines, 1 file), root (4 lines, 1 file) and 1 more
  • Lines are git diff --numstat HEAD (added + removed); new untracked files are counted separately.
  • The split groups the changes by top-level folder, biggest first.
  • The toast comes once, then again only when the pile has grown by half. The band stays while the pile stays over the threshold and leaves after you commit.
  • Silent outside a git repository.
  • /jrcommit hides or shows the band, in every session and across restarts; /jrcommit on and /jrcommit off set it.

Settings (/config)

OptionDefault
Threshold (lines)300Uncommitted lines from which the coach speaks up
Show the bandonThe line above the prompt

Install

/plugin install jr-commit-coach --marketplace jrlprost/claude-code-mods

Access

Runs git diff --numstat HEAD and git ls-files --others --exclude-standard in the session's folder after each turn. Never commits anything. Sends nothing over the network. Remembers whether the band is hidden.

Source 4 files
hooks/register.tsx 109 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { advice, pending } from './coach'
5import { repoIsSafe, safeGit } from './jr/git'
6
7const nudge = atom({ plugin: 'jr-commit-coach', key: 'nudge' } as const, null)
8const bandHidden = atom({ plugin: 'jr-commit-coach', key: 'isBandHidden' } as const, false)
9const commands = { isRegistered: false }
10const band = { isLoaded: false }
11
12async function ensureCommand($: EngineInterface) {
13  if (commands.isRegistered) return
14  commands.isRegistered = true
15  await $.command.register({ name: 'jrcommit', description: 'Show or hide the uncommitted-work band: /jrcommit toggles, /jrcommit on or off' })
16}
17
18/** True while the band is hidden: one choice for every session, read from the store once per load. */
19async function isBandHidden($: EngineInterface) {
20  if (!band.isLoaded) {
21    band.isLoaded = true
22    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
23  }
24  return read($, bandHidden)
25}
26
27// The last size a toast was shown for, so the same pile does not toast again every turn.
28const shown = { lines: 0 }
29
30/** Uncommitted lines and new files in the session's repository, or null outside git. */
31async function measure($: EngineInterface) {
32  const root = await $.session.root()
33  // A repo whose config or attributes could make git run a program is left alone.
34  if (!(await repoIsSafe($, root))) return null
35  const diff = await $.process.run(safeGit(['-C', root, 'diff', '--numstat', 'HEAD'])).catch(() => null)
36  if (!diff || diff.exitCode !== 0) return null
37  const fresh = await $.process.run(safeGit(['-C', root, 'ls-files', '--others', '--exclude-standard'])).catch(() => null)
38  return pending(diff.stdout, fresh?.exitCode === 0 ? fresh.stdout : '')
39}
40
41export const register: Register = (on, options) => {
42  const threshold = Number(options.threshold ?? 300)
43  const showBand = options.showBand !== false
44
45  on('session.start', async ($, e, next) => {
46    const started = await next(e)
47    await ensureCommand($)
48    return started
49  })
50
51  on('turn.complete', async ($, e, next) => {
52    const done = await next(e)
53    if (e.agentId !== undefined) return done
54    const p = await measure($)
55    if (!p || p.lines < threshold) {
56      shown.lines = 0
57      await update($, nudge, () => null)
58      return done
59    }
60    const text = advice(p)
61    await update($, nudge, () => ({ lines: p.lines, untracked: p.untracked, advice: text, folders: p.split.slice(0, 3).map(g => `${g.folder}/`) }))
62    // A toast the first time, then again only when the pile grew by half.
63    if (p.lines >= Math.max(threshold, shown.lines * 1.5)) {
64      shown.lines = p.lines
65      $.ui.toast(`jr commit coach · ${text}`)
66    }
67    return done
68  })
69
70  on('command.run', { command: 'jrcommit' }, async ($, e) => {
71    const arg = e.args.trim().toLowerCase()
72    const hide = arg === 'off' || arg === 'hide' ? true : arg === 'on' || arg === 'show' ? false : arg === '' ? !(await isBandHidden($)) : null
73    if (hide === null) return { text: 'jr commit coach · usage: /jrcommit toggles the band, /jrcommit on or off' }
74    await $.store.set('isBandHidden', hide)
75    await update($, bandHidden, () => hide)
76    return { text: hide ? 'jr commit coach · hidden (/jrcommit to bring it back)' : 'jr commit coach · shown' }
77  })
78
79  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80    const below = await next(e)
81    if (!commands.isRegistered) {
82      $.clock.after(0, () => {
83        void ensureCommand($)
84      })
85    }
86    // Never write state while drawing: the saved choice is loaded just after.
87    if (!band.isLoaded) {
88      $.clock.after(0, () => {
89        void isBandHidden($)
90      })
91    }
92    if (await read($, bandHidden)) return below
93    const n = await read($, nudge)
94    if (!showBand || !n || e.props.hasSurvey) return below
95    const { Box, Text } = $.ui.resolve(e)
96    return (
97      <Box flexDirection="column">
98        <Text wrap="truncate-end">
99          <Text color="#ff8c42">{'▲ '}</Text>
100          <Text bold>{`${n.lines}`}</Text>
101          <Text>{` lines uncommitted${n.untracked ? ` + ${n.untracked} new` : ''}`}</Text>
102          {n.folders.length > 0 && <Text dimColor>{` · split: ${n.folders.join(' ')}`}</Text>}
103        </Text>
104        {below}
105      </Box>
106    )
107  })
108}
109
hooks/coach.ts 38 lines
1/** `git diff --numstat HEAD` lines into files with their changed line counts (binary files count 0). */
2export const parseNumstat = (stdout: string) =>
3  stdout
4    .split('\n')
5    .map(line => line.split('\t'))
6    .filter(parts => parts.length >= 3)
7    .map(([a = '0', r = '0', ...rest]) => ({ file: rest.join('\t'), lines: (Number(a) || 0) + (Number(r) || 0) }))
8
9/** Changed lines grouped by top-level folder (or "root" for files at the top), biggest first. */
10export const splitByFolder = (files: readonly { file: string; lines: number }[]) => {
11  const groups = new Map<string, { lines: number; files: number }>()
12  for (const f of files) {
13    const top = f.file.includes('/') ? f.file.split('/')[0]! : 'root'
14    const g = groups.get(top) ?? { lines: 0, files: 0 }
15    groups.set(top, { lines: g.lines + f.lines, files: g.files + 1 })
16  }
17  return [...groups.entries()].map(([folder, g]) => ({ folder, ...g })).sort((a, b) => b.lines - a.lines)
18}
19
20export type Pending = { lines: number; files: number; untracked: number; split: { folder: string; lines: number; files: number }[] }
21
22export const pending = (numstat: string, untracked: string): Pending => {
23  const files = parseNumstat(numstat)
24  const fresh = untracked.split('\n').filter(Boolean)
25  return {
26    lines: files.reduce((n, f) => n + f.lines, 0),
27    files: files.length,
28    untracked: fresh.length,
29    split: splitByFolder([...files, ...fresh.map(file => ({ file, lines: 0 }))]),
30  }
31}
32
33export const advice = (p: Pending) => {
34  const parts = p.split.slice(0, 3).map(g => `${g.folder} (${g.lines} lines, ${g.files} file${g.files > 1 ? 's' : ''})`)
35  const more = p.split.length > 3 ? ` and ${p.split.length - 3} more` : ''
36  return `${p.lines} lines uncommitted${p.untracked ? `, ${p.untracked} new file${p.untracked > 1 ? 's' : ''}` : ''}: commit in small steps, e.g. ${parts.join(', ')}${more}`
37}
38
hooks/jr/git.ts 83 lines
1// git in a repository the mod does not trust. A repository's config can make git run programs:
2// fsmonitor, external diff and textconv drivers, clean/smudge filters, ssh and credential helpers,
3// pagers, editors, aliases, or an include pulling any of these from another file.
4// Two layers: config overrides on every call, and repoIsSafe() before any command that reads the
5// working tree. repoIsSafe asks git itself which config it would use there (so there is no parser
6// of ours to fool) and refuses when an entry able to run a program comes from anywhere but the
7// person's own setup (what git reads outside any repository).
8// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
9import type { EngineInterface } from 'claude-code'
10
11const SAFE_CONFIG = [
12  '-c', 'core.fsmonitor=false',
13  '-c', 'core.hooksPath=/dev/null',
14  '-c', 'core.pager=cat',
15  '-c', 'core.editor=true',
16  '-c', 'core.sshCommand=false',
17  '-c', 'core.askPass=false',
18  '-c', 'credential.helper=',
19  '-c', 'diff.external=',
20  '-c', 'core.untrackedCache=false',
21  '-c', 'protocol.ext.allow=never',
22  // Never step into submodules: each has its own config, which repoIsSafe does not read.
23  '-c', 'submodule.recurse=false',
24  '-c', 'diff.ignoreSubmodules=all',
25  '-c', 'status.submoduleSummary=false',
26]
27
28/** `git <args>` with command-running config switched off. Diffs never run external drivers. */
29export const safeGit = (args: readonly string[]) => {
30  const rest = [...args]
31  if (rest[0] === '-C') rest.splice(0, 2)
32  const cwd = args[0] === '-C' ? ['-C', args[1] ?? '.'] : []
33  const extra = rest[0] === 'diff' ? ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'] : []
34  return ['git', ...SAFE_CONFIG, ...cwd, ...(rest.length ? [rest[0]!, ...extra, ...rest.slice(1)] : [])]
35}
36
37export type GitConfigEntry = { origin: string; key: string; value: string }
38
39/** Parses `git config --list --show-origin -z`: records of origin NUL key LF value NUL. */
40export const parseConfigListing = (listing: string): GitConfigEntry[] => {
41  const parts = listing.split('\0')
42  const entries: GitConfigEntry[] = []
43  for (let i = 0; i + 1 < parts.length; i += 2) {
44    const origin = parts[i]!
45    const record = parts[i + 1]!
46    const nl = record.indexOf('\n')
47    entries.push({ origin, key: (nl < 0 ? record : record.slice(0, nl)).toLowerCase(), value: nl < 0 ? '' : record.slice(nl + 1) })
48  }
49  return entries
50}
51
52// The last part of a key (git keys are section[.subsection].name, already lower-cased by git
53// for section and name) that makes git run a program, plus whole sections that do.
54const RISKY_NAME = /\.(fsmonitor|sshcommand|askpass|editor|pager|hookspath|textconv|command|clean|smudge|process|driver|helper|program|external|packobjectshook|gitproxy)$/
55const RISKY_SECTION = /^(alias|include|includeif)\./
56
57/** Entries able to run a program that come from somewhere other than the trusted origins. */
58export const riskyEntries = (entries: readonly GitConfigEntry[], trustedOrigins: ReadonlySet<string>) =>
59  entries.filter(e => !trustedOrigins.has(e.origin) && !e.origin.startsWith('command line:') && (RISKY_NAME.test(e.key) || RISKY_SECTION.test(e.key)))
60
61async function listing($: EngineInterface, args: readonly string[]) {
62  try {
63    const out = await $.process.run(args)
64    return out.exitCode === 0 ? out.stdout : null
65  } catch {
66    return null
67  }
68}
69
70/**
71 * True only when the config git would use at `root` holds nothing able to run a program beyond
72 * what the person's own global and system config set up. Any failure counts as unsafe.
73 */
74export async function repoIsSafe($: EngineInterface, root: string) {
75  const local = await listing($, safeGit(['-C', root, 'config', '--list', '--show-origin', '-z']))
76  if (local === null) return false
77  // What git reads outside any repository is the person's own setup: global, system, and the
78  // config the git build ships (Apple's git keeps it next to Xcode, not in /etc/gitconfig).
79  const own = await listing($, safeGit(['-C', '/', 'config', '--list', '--show-origin', '-z']))
80  const trusted = new Set(parseConfigListing(own ?? '').map(e => e.origin))
81  return riskyEntries(parseConfigListing(local), trusted).length === 0
82}
83
types/index.d.ts 9 lines
1/** The uncommitted work above the threshold, as the band shows it. */
2export type JrCommitCoachNudge = { lines: number; untracked: number; advice: string; folders: string[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'jr-commit-coach': { nudge: JrCommitCoachNudge | null; isBandHidden: boolean }
7  }
8}
9