SLOPSHOPPER

repo-sentry

Status line with business, repo, branch and dirty count, plus a warning band when on main or a secret file is staged

newbandguardstatusprocesstimer
v0.2.2no licenseupdated 2026-10-08jgilb17/claude-mods/repo-sentry
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-sentry
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ repo-sentry: app | app@feat/auth-refresh | no upstream | 2 dirty, 2 staged
README

Joshua's Claude Code mods

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.

ModWhat it shows
repo-sentryBusiness, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret.
progress-pulseA live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run.
usage-meterThe 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it.

How they load

  • Mac: setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.
  • Cloud: cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.

Adding a mod

Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.

Other tools here

mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.

Source 3 files
hooks/register.tsx 86 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoInfo } from '../types'
5import { buildInfo, statusText } from './logic'
6
7const info = atom({ plugin: 'repo-sentry', key: 'info' } as const, null)
8const hiddenFor = atom({ plugin: 'repo-sentry', key: 'hiddenFor' } as const, '')
9
10async function git($: EngineInterface, args: string[], timeoutMs = 5000): Promise<string | null> {
11  try {
12    const r = await $.process.run(['git', ...args], { timeoutMs })
13    return r.exitCode === 0 ? r.stdout : null
14  } catch {
15    return null
16  }
17}
18
19async function refresh($: EngineInterface): Promise<void> {
20  const top = await git($, ['rev-parse', '--show-toplevel'])
21  let next: RepoInfo | null = null
22  if (top !== null) {
23    const [branch, porcelain, staged, aheadBehind] = await Promise.all([
24      git($, ['rev-parse', '--abbrev-ref', 'HEAD']),
25      git($, ['status', '--porcelain']),
26      git($, ['diff', '--cached', '--name-only']),
27      git($, ['rev-list', '--left-right', '--count', 'HEAD...@{u}']),
28    ])
29    next = buildInfo(top, branch ?? '', porcelain ?? '', staged ?? '', aheadBehind)
30  }
31  await update($, info, () => next)
32  $.ui.status(statusText(next))
33}
34
35export const register: Register = on => {
36  // Behind-origin is only as fresh as the last fetch, and both stale repos found on 8 Oct had
37  // not been fetched in weeks. So: draw from local state at once, then fetch quietly (refs only,
38  // repo hooks off, 15 s cap) and redraw, again every 10 minutes for long sessions. The fetch
39  // runs on a $.clock timer because work that outlives the session.start dispatch belongs there.
40  // A failed fetch (offline, no remote) changes nothing.
41  on('session.start', async ($, e, next) => {
42    const r = await next(e)
43    await refresh($)
44    const fetchAndRefresh = () => {
45      void git($, ['fetch', '--quiet', '--no-tags'], 15000).then(() => refresh($)).catch(() => {})
46    }
47    $.clock.after(1000, fetchAndRefresh)
48    $.clock.every(10 * 60_000, fetchAndRefresh)
49    return r
50  })
51
52  on('turn.complete', async ($, e, next) => {
53    const r = await next(e)
54    await refresh($)
55    return r
56  })
57
58  // Git commands the model runs can change branch or staging mid-turn.
59  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
60    const r = await next(e)
61    if (/\bgit\b/.test(e.command)) await refresh($)
62    return r
63  }).catch(($, e, next) => next(e))
64
65  // Draws above whatever is beneath (another mod's band, the engine's own) rather than in its
66  // place, so two mods on the band both show.
67  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68    const below = await next(e)
69    const now = await read($, info)
70    if (e.props.hasSurvey || !now || now.warnings.length === 0) return below
71    const sig = now.warnings.join('|')
72    if ((await read($, hiddenFor)) === sig) return below
73
74    const { Box, Button, Text } = $.ui.resolve(e)
75    return (
76      <Box flexDirection="column">
77        {now.warnings.map(w => (
78          <Text color="warning">{now.repo}: {w}</Text>
79        ))}
80        <Button key="hide" label="Hide" onPress={() => update($, hiddenFor, () => sig)} />
81        {below}
82      </Box>
83    )
84  })
85}
86
hooks/logic.ts 71 lines
1import type { RepoInfo } from '../types'
2
3// Repo folder name -> business label. Edit this map as repos are added.
4export const BUSINESS: Record<string, string> = {
5  'apek-hub': 'APEK',
6  'df-correspondence': 'Desert Fire',
7  jpg: 'JPG',
8  'claude-guard': 'Shared',
9}
10
11const PROTECTED_BRANCHES = new Set(['main', 'master', 'production', 'prod'])
12const SECRET = /(^|\/)(\.env(\..+)?|secrets?\/.*|.*\.pem|.*\.key|credentials\.json|service-account.*\.json)$/i
13
14export function businessFor(repo: string): string {
15  const name = repo.toLowerCase()
16  if (BUSINESS[name]) return BUSINESS[name]
17  if (name.startsWith('apek')) return 'APEK'
18  if (name.startsWith('df-') || name.includes('desert-fire')) return 'Desert Fire'
19  if (name.startsWith('fwd') || name.includes('first-wave')) return 'First Wave Dental'
20  if (name.startsWith('jpg')) return 'JPG'
21  return repo
22}
23
24const lines = (s: string) => s.split('\n').map(l => l.trim()).filter(Boolean)
25
26// aheadBehind is the output of `git rev-list --left-right --count HEAD...@{u}`: "<ahead>\t<behind>",
27// or null when the branch has no upstream.
28export function parseAheadBehind(out: string | null): { ahead: number | null; behind: number | null } {
29  const m = (out ?? '').trim().match(/^(\d+)\s+(\d+)$/)
30  return m ? { ahead: Number(m[1]), behind: Number(m[2]) } : { ahead: null, behind: null }
31}
32
33export function buildInfo(toplevel: string, branch: string, porcelain: string, stagedNames: string, aheadBehind: string | null = null): RepoInfo {
34  const repo = toplevel.trim().split('/').filter(Boolean).pop() ?? toplevel.trim()
35  const b = branch.trim() || 'detached'
36  const staged = lines(stagedNames)
37  const warnings: string[] = []
38  // On a clean main there is nothing to protect yet, and every session opens there, so the
39  // warning only appears once there is work that would land on it.
40  const dirtyCount = lines(porcelain).length
41  if (PROTECTED_BRANCHES.has(b) && (dirtyCount > 0 || staged.length > 0)) warnings.push(`${dirtyCount + staged.length} changes on ${b}. Branch before committing.`)
42  if (b === 'HEAD') warnings.push('Detached HEAD.')
43  const { ahead, behind } = parseAheadBehind(aheadBehind)
44  const dirty = lines(porcelain).length
45  if (behind && behind > 0) {
46    warnings.push(dirty > 0
47      ? `${behind} commits behind origin with ${dirty} uncommitted files. Park them on a branch, then pull.`
48      : `${behind} commits behind origin. Pull before working.`)
49  }
50  const secrets = staged.filter(f => SECRET.test(f))
51  if (secrets.length) warnings.push(`Secret file staged: ${secrets.slice(0, 3).join(', ')}${secrets.length > 3 ? ` (+${secrets.length - 3})` : ''}. Unstage it.`)
52  return {
53    business: businessFor(repo),
54    repo,
55    branch: b,
56    dirty,
57    staged: staged.length,
58    behind,
59    ahead,
60    warnings,
61  }
62}
63
64export function statusText(info: RepoInfo | null): string | undefined {
65  if (!info) return undefined
66  const flag = info.warnings.length ? ' | !' : ''
67  const sync = info.behind === null ? ' | no upstream'
68    : info.behind || info.ahead ? ` | ${info.behind} behind, ${info.ahead} ahead` : ' | in sync'
69  return `${info.business} | ${info.repo}@${info.branch}${sync} | ${info.dirty} dirty, ${info.staged} staged${flag}`
70}
71
types/index.d.ts 17 lines
1export type RepoInfo = {
2  business: string
3  repo: string
4  branch: string
5  dirty: number
6  staged: number
7  behind: number | null
8  ahead: number | null
9  warnings: string[]
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'repo-sentry': { info: RepoInfo | null; hiddenFor: string }
15  }
16}
17